返回 ppt-master
register_template.py
根目录 / skills / ppt-master / scripts / register_template.py
1 #!/usr/bin/env python3
2 """Register a brand / style / layout / deck template in the global index.
3
4 Four kinds, four workspace roots, four index files. The shared model lives
5 in ``templates/README.md``; each kind's schema lives in its directory README:
6
7 | --kind | Workspace roots | Index file |
8 |---------|-------------------------|-------------------------------|
9 | brand | ``templates/brands/`` | ``brands_index.json`` |
10 | style | ``templates/styles/`` | ``styles_index.json`` |
11 | layout | ``templates/layouts/`` | ``layouts_index.json`` |
12 | deck | ``templates/decks/`` | ``decks_index.json`` |
13
14 Current workspaces keep ``design_spec.md`` and any SVG roster under
15 ``<workspace>/templates/``. Assets live in optional ``images/`` / ``icons/``
16 directories. Explicitly generated review artifacts go to the optional, ignored
17 ``exports/`` directory. Legacy flat roots remain readable for Brand/Layout/Deck;
18 Style uses only the current nested one-file contract.
19
20 Index entry schemas (the JSON file is the single source of truth — README
21 files describe the kind and usage in prose but do **not** enumerate templates;
22 discovery happens exclusively against the index file):
23
24 - brand: ``{ summary, primary_color }``
25 - style: ``{ summary, keywords[] }``
26 - layout: ``{ summary, canvas_format, page_count, page_types[] }``
27 - deck: ``{ summary, canvas_format, page_count, primary_color }``
28
29 Usage::
30
31 python3 scripts/register_template.py <id> --kind deck # default kind=deck
32 python3 scripts/register_template.py <id> --kind layout
33 python3 scripts/register_template.py <id> --kind brand
34 python3 scripts/register_template.py <id> --kind style
35 python3 scripts/register_template.py --rebuild-all --kind deck
36 python3 scripts/register_template.py <id> --dry-run
37
38 ``--rebuild-all`` rebuilds every entry from scratch within the chosen kind;
39 recommended for repairing index drift across many templates at once.
40
41 Project-scoped Brand and Style workspaces are validated, not registered,
42 through ``svg_quality_checker.py <workspace>/templates --template-mode``. That
43 entry reuses :func:`validate_brand_workspace` or
44 :func:`validate_style_workspace`, so each schema has one authority.
45 """
46
47 from __future__ import annotations
48
49 import argparse
50 import json
51 import re
52 import sys
53 from collections import OrderedDict
54 from pathlib import Path
55 from xml.etree import ElementTree as ET
56
57 from attribution_guard import require_skill_integrity
58 from console_encoding import configure_utf8_stdio
59 from config import CANVAS_FORMATS
60
61 try:
62 import yaml # type: ignore
63 except ImportError:
64 yaml = None
65
66
67 configure_utf8_stdio()
68
69
70 SCRIPT_DIR = Path(__file__).resolve().parent
71 SKILL_DIR = SCRIPT_DIR.parent
72 TEMPLATES_DIR = SKILL_DIR / "templates"
73
74 KIND_CONFIG = {
75 "brand": {
76 "dir": TEMPLATES_DIR / "brands",
77 "index": TEMPLATES_DIR / "brands" / "brands_index.json",
78 "id_key": "brand_id",
79 "needs_svg_roster": False,
80 },
81 "style": {
82 "dir": TEMPLATES_DIR / "styles",
83 "index": TEMPLATES_DIR / "styles" / "styles_index.json",
84 "id_key": "style_id",
85 "needs_svg_roster": False,
86 },
87 "layout": {
88 "dir": TEMPLATES_DIR / "layouts",
89 "index": TEMPLATES_DIR / "layouts" / "layouts_index.json",
90 "id_key": "layout_id",
91 "needs_svg_roster": True,
92 },
93 "deck": {
94 "dir": TEMPLATES_DIR / "decks",
95 "index": TEMPLATES_DIR / "decks" / "decks_index.json",
96 "id_key": "deck_id",
97 "needs_svg_roster": True,
98 },
99 }
100
101 _BRAND_REQUIRED_SECTIONS = (
102 ("I", "Brand Overview"),
103 ("II", "Color Scheme"),
104 ("III", "Typography"),
105 ("IV", "Logo"),
106 ("V", "Voice & Tone"),
107 ("VI", "Icon Style"),
108 )
109 _BRAND_FORBIDDEN_SECTIONS = (
110 "Page Roster",
111 "Signature Design Elements",
112 )
113 _BRAND_ALLOWED_FRONTMATTER_FIELDS = frozenset({
114 "brand_id",
115 "kind",
116 "summary",
117 "primary_color",
118 })
119 _BRAND_PROVENANCE_VALUES = {"fact", "approx", "user"}
120 _BRAND_ASSET_REF_RE = re.compile(
121 r"`((?:\.\./)+(?:images|icons)/[^`]+)`"
122 )
123 _STYLE_REQUIRED_SECTIONS = (
124 ("I", "Style Overview"),
125 ("II", "Communication Method"),
126 ("III", "Page Role Vocabulary"),
127 ("IV", "Evidence & Data Expression"),
128 ("V", "Visual System Defaults"),
129 ("VI", "Image & Icon Direction"),
130 ("VII", "Review Focus"),
131 )
132 _STYLE_FORBIDDEN_SECTIONS = (
133 "Brand Overview",
134 "Template Overview",
135 "Color Scheme",
136 "Typography",
137 "Logo",
138 "Voice & Tone",
139 "Icon Style",
140 "Assets",
141 "Signature Design Elements",
142 "Page Roster",
143 "Placeholder Overrides",
144 )
145 _STYLE_REQUIRED_FIELDS = {
146 "I. Style Overview": (
147 "Style Name",
148 "Best Fit",
149 "Reusable Intent",
150 "Sources",
151 ),
152 "II. Communication Method": (
153 "Argument Flow",
154 "Page Message Discipline",
155 "Claim Discipline",
156 ),
157 "IV. Evidence & Data Expression": (
158 "Argument Trace",
159 "Charts",
160 "Tables",
161 "Sources",
162 "Native Editability",
163 ),
164 "V. Visual System Defaults": (
165 "Composition",
166 "Density",
167 "Decoration",
168 "Color Behavior",
169 "Typography Character",
170 ),
171 "VI. Image & Icon Direction": (
172 "Image Usage",
173 "Image Treatment",
174 "Icon Treatment",
175 ),
176 }
177 _STYLE_CUSTOM_FIELDS = (
178 (
179 "II. Communication Method",
180 "Preferred Mode",
181 "Mode Behavior",
182 "Mode References",
183 SKILL_DIR / "references" / "modes",
184 ),
185 (
186 "V. Visual System Defaults",
187 "Preferred Visual Style",
188 "Visual Style Behavior",
189 "Visual Style References",
190 SKILL_DIR / "references" / "visual-styles",
191 ),
192 (
193 "VI. Image & Icon Direction",
194 "Preferred Image Rendering",
195 "Image Rendering Behavior",
196 "Image Rendering References",
197 SKILL_DIR / "references" / "image-renderings",
198 ),
199 )
200 _STYLE_FORBIDDEN_FIELDS = (
201 "Brand Overview",
202 "Template Overview",
203 "Color Scheme",
204 "Typography",
205 "Signature Design Elements",
206 "Page Roster",
207 "Placeholder Overrides",
208 "Target Audience",
209 "Communication Objective",
210 "Desired Outcome",
211 "Core Message",
212 "Delivery Context",
213 "Artifact Afterlife",
214 "Application Context",
215 "Content Outline",
216 "Page Count",
217 "Page Types",
218 "Page Order",
219 "Page Sequence",
220 "Canvas Format",
221 "Canvas Width",
222 "Canvas Height",
223 "Canvas ViewBox",
224 "Replication Mode",
225 "Native Structure Mode",
226 "Master",
227 "Layout",
228 "Placeholders",
229 "Page Assignment",
230 "Icon Inventory",
231 "Image Resources",
232 "Primary Color",
233 "Logo",
234 "Voice & Tone",
235 "Icon Style",
236 "Assets",
237 )
238 _STYLE_ALLOWED_FRONTMATTER_FIELDS = frozenset({
239 "style_id",
240 "kind",
241 "summary",
242 "keywords",
243 })
244 _STYLE_REVIEW_TRIGGER_MARKER = "<!-- visual-review-trigger: explicit-user-only -->"
245 _HEX_COLOR_RE = re.compile(r"#[0-9A-Fa-f]{6}")
246 _PORTABLE_STYLE_ID_RE = re.compile(r"^\w[\w.-]*$")
247
248
249 # ---------------------------------------------------------------------------
250 # design_spec.md parsing
251 # ---------------------------------------------------------------------------
252
253 class SpecParseError(RuntimeError):
254 """Raised when a design_spec.md cannot be turned into an index entry."""
255
256
257 def _read_spec(spec_path: Path) -> tuple[dict | None, str]:
258 """Split YAML frontmatter from the body. Returns ``(frontmatter, body)``."""
259 text = spec_path.read_text(encoding="utf-8")
260 if not text.startswith("---\n"):
261 return None, text
262 end = text.find("\n---\n", 4)
263 if end == -1:
264 return None, text
265 fm_block = text[4:end]
266 body = text[end + 5:]
267 if yaml is None:
268 raise SpecParseError(
269 "design_spec.md has YAML frontmatter but PyYAML is not installed; "
270 "install pyyaml or remove the frontmatter."
271 )
272 try:
273 data = yaml.safe_load(fm_block) or {}
274 except yaml.YAMLError as exc:
275 raise SpecParseError(f"invalid YAML frontmatter: {exc}") from exc
276 if not isinstance(data, dict):
277 raise SpecParseError("YAML frontmatter must be a mapping")
278 return data, body
279
280
281 def _extract_section_field(body: str, section_title: str, labels: list[str]) -> str | None:
282 section_re = re.compile(
283 rf"^##\s+{re.escape(section_title)}\b.*?(?=^##\s+|\Z)",
284 re.MULTILINE | re.DOTALL,
285 )
286 section_match = section_re.search(body)
287 if section_match is None:
288 return None
289 section = section_match.group(0)
290
291 for label in labels:
292 row = re.search(
293 rf"^\|\s*\*?\*?{re.escape(label)}\*?\*?\s*\|\s*(.+?)\s*\|",
294 section, re.MULTILINE | re.IGNORECASE,
295 )
296 if row:
297 return _clean_field_value(row.group(1))
298
299 bullet = re.search(
300 rf"^[-*]\s*\*?\*?{re.escape(label)}\*?\*?\s*[::]\s*(.+?)\s*$",
301 section, re.MULTILINE | re.IGNORECASE,
302 )
303 if bullet:
304 return _clean_field_value(bullet.group(1))
305 return None
306
307
308 def _clean_field_value(value: str) -> str:
309 value = value.strip()
310 value = re.sub(r"^[`*_]+", "", value)
311 value = re.sub(r"[`*_]+$", "", value)
312 return value.strip()
313
314
315 def _find_first_color(section: str) -> str | None:
316 match = re.search(r"`(#[0-9A-Fa-f]{3,8})`", section)
317 return match.group(1).upper() if match else None
318
319
320 def _extract_primary_color(body: str) -> str | None:
321 section_match = re.search(
322 r"^##\s+[IVX]+\.\s+Color Scheme\b.*?(?=^##\s+|\Z)",
323 body, re.MULTILINE | re.DOTALL,
324 )
325 if section_match is None:
326 return None
327 return _find_first_color(section_match.group(0))
328
329
330 def _summary_from_use_cases(use_cases: str | None) -> str | None:
331 if not use_cases:
332 return None
333 cleaned = use_cases.strip().rstrip(".")
334 if not cleaned:
335 return None
336 return f"{cleaned}."
337
338
339 _SPEC_NAME_RE = re.compile(
340 r"design_spec\.(?P<kind>brand|style|layout|deck)\.(?P<id>[^/\\]+)\.md"
341 )
342
343
344 def _has_kind_qualified_spec(directory: Path) -> bool:
345 """Report whether a directory holds any ``design_spec.<kind>.<id>.md``."""
346 if not directory.is_dir():
347 return False
348 return any(
349 _SPEC_NAME_RE.fullmatch(path.name)
350 for path in directory.glob("design_spec.*.md")
351 )
352
353
354 def _qualified_template_specs(directory: Path) -> list[tuple[Path, str]]:
355 """Return kind-qualified specs and their filename-declared kinds."""
356 if not directory.is_dir():
357 return []
358 specs = []
359 for path in sorted(directory.glob("design_spec.*.md")):
360 match = _SPEC_NAME_RE.fullmatch(path.name)
361 if match is not None:
362 specs.append((path, match.group("kind")))
363 return specs
364
365
366 def validate_qualified_spec_identity(
367 spec_path: str | Path,
368 ) -> tuple[str, str, dict, str]:
369 """Match one qualified filename's kind and id to its frontmatter."""
370 path = Path(spec_path)
371 match = _SPEC_NAME_RE.fullmatch(path.name)
372 if match is None:
373 raise SpecParseError(
374 "qualified Design Spec must be named "
375 "design_spec.<kind>.<id>.md: "
376 f"{path.name}"
377 )
378
379 filename_kind = match.group("kind")
380 filename_id = match.group("id")
381 frontmatter, body = _read_spec(path)
382 fm = frontmatter or {}
383 declared_kind = str(fm.get("kind") or "").strip()
384 id_key = KIND_CONFIG[filename_kind]["id_key"]
385 declared_id = str(fm.get(id_key) or "").strip()
386 errors: list[str] = []
387 if declared_kind != filename_kind:
388 errors.append(
389 f"filename kind {filename_kind!r} must match frontmatter kind "
390 f"{declared_kind!r}"
391 )
392 if declared_id != filename_id:
393 errors.append(
394 f"filename id {filename_id!r} must match frontmatter {id_key} "
395 f"{declared_id!r}"
396 )
397 if errors:
398 details = "\n".join(f" - {error}" for error in errors)
399 raise SpecParseError(
400 f"invalid qualified Design Spec identity ({path.name}):\n{details}"
401 )
402 return filename_kind, filename_id, fm, body
403
404
405 def _validate_spec_shape(template_dir: Path) -> list[tuple[Path, str]]:
406 """Reject ambiguous project-spec naming and duplicate kind ownership."""
407 exact = template_dir / "design_spec.md"
408 qualified = _qualified_template_specs(template_dir)
409 if exact.is_file() and qualified:
410 raise SpecParseError(
411 "design_spec.md and design_spec.<kind>.<id>.md cannot share "
412 f"{template_dir}; rename the bare spec to its kind-qualified name"
413 )
414 kinds = [kind for _path, kind in qualified]
415 duplicate_kinds = sorted({
416 kind for kind in kinds if kinds.count(kind) > 1
417 })
418 if duplicate_kinds:
419 raise SpecParseError(
420 f"{template_dir} declares the same kind more than once: "
421 + ", ".join(duplicate_kinds)
422 )
423 for path, _kind in qualified:
424 validate_qualified_spec_identity(path)
425 return qualified
426
427
428 def _has_qualified_roster_spec(template_dir: Path) -> bool:
429 """Return whether a project directory declares a structural kind."""
430 return any(
431 kind in {"layout", "deck"}
432 for _path, kind in _qualified_template_specs(template_dir)
433 )
434
435
436 def _template_content_dir(template_root: Path) -> Path:
437 """Resolve the canonical source directory, with legacy-flat compatibility."""
438 nested = template_root / "templates"
439 if (nested / "design_spec.md").is_file() or _has_kind_qualified_spec(nested):
440 return nested
441 if (template_root / "design_spec.md").is_file():
442 return template_root
443 raise SpecParseError(
444 "missing templates/design_spec.md, templates/design_spec.<kind>.<id>.md, "
445 f"or legacy design_spec.md in {template_root}"
446 )
447
448
449 def _list_pages(template_dir: Path) -> list[str]:
450 return sorted(p.stem for p in template_dir.glob("*.svg"))
451
452
453 def _derive_page_types(pages: list[str]) -> list[str]:
454 """Derive canonical page-type list from SVG filenames (strips leading 'NN_')."""
455 types: list[str] = []
456 seen: set[str] = set()
457 for p in pages:
458 m = re.match(r"^\d+[a-z]?_(.+)$", p)
459 role = m.group(1) if m else p
460 if role not in seen:
461 seen.add(role)
462 types.append(role)
463 return types
464
465
466 def _numbered_section(body: str, title: str) -> str | None:
467 match = re.search(
468 rf"^##\s+[IVX]+\.\s+{re.escape(title)}\s*$.*?(?=^##\s+|\Z)",
469 body,
470 re.MULTILINE | re.DOTALL,
471 )
472 return match.group(0) if match else None
473
474
475 def _markdown_subsection(body: str, title: str) -> str | None:
476 match = re.search(
477 rf"^###\s+{re.escape(title)}\s*$.*?(?=^#{{2,3}}\s+|\Z)",
478 body,
479 re.MULTILINE | re.DOTALL,
480 )
481 return match.group(0) if match else None
482
483
484 def _markdown_table_rows(section: str) -> list[list[str]]:
485 rows: list[list[str]] = []
486 for line in section.splitlines():
487 if not line.lstrip().startswith("|"):
488 continue
489 cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
490 if not cells or all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells):
491 continue
492 rows.append(cells)
493 return rows
494
495
496 def _style_value_is_substantive(value: str | None) -> bool:
497 if value is None:
498 return False
499 cleaned = _clean_field_value(value)
500 if not cleaned or re.fullmatch(r"<[^>]+>", cleaned):
501 return False
502 return cleaned.casefold() not in {"tbd", "todo", "n/a", "none", "-", "—"}
503
504
505 def _style_field_is_declared(body: str, label: str) -> bool:
506 escaped = re.escape(label)
507 return bool(
508 re.search(
509 rf"^\s*(?:>\s*)?(?:[-*]\s*)?"
510 rf"\*{{0,2}}{escaped}\*{{0,2}}\s*[::]",
511 body,
512 re.MULTILINE | re.IGNORECASE,
513 )
514 or re.search(
515 rf"^\|\s*\*{{0,2}}{escaped}\*{{0,2}}\s*\|",
516 body,
517 re.MULTILINE | re.IGNORECASE,
518 )
519 )
520
521
522 def _validate_brand_spec(
523 expected_template_id: str | None,
524 template_root: Path,
525 template_dir: Path,
526 frontmatter: dict,
527 body: str,
528 pages: list[str],
529 ) -> None:
530 """Reject brand workspaces that cannot be locked as portable identity truth.
531
532 Args:
533 expected_template_id: Registry key to match in library scope. Project
534 workspaces pass ``None`` because their root name is the project id,
535 not the portable brand id.
536 template_root: Brand workspace root containing assets and templates.
537 template_dir: Directory containing ``design_spec.md`` and any page SVGs.
538 frontmatter: Parsed design-spec frontmatter.
539 body: Markdown content after the frontmatter block.
540 pages: SVG page stems discovered beside the design spec.
541 """
542 errors: list[str] = []
543
544 declared_id = str(frontmatter.get("brand_id") or "").strip()
545 if not declared_id:
546 errors.append("frontmatter brand_id must be non-empty")
547 elif expected_template_id is not None and declared_id != expected_template_id:
548 errors.append(
549 "frontmatter brand_id must match directory "
550 f"{expected_template_id!r}, "
551 f"got {declared_id!r}"
552 )
553
554 declared_kind = str(frontmatter.get("kind") or "").strip()
555 if declared_kind != "brand":
556 errors.append(
557 "frontmatter kind must be 'brand', "
558 f"got {declared_kind!r}"
559 )
560
561 if not str(frontmatter.get("summary") or "").strip():
562 errors.append("frontmatter summary must be non-empty")
563
564 unexpected_fields = sorted(
565 set(frontmatter) - _BRAND_ALLOWED_FRONTMATTER_FIELDS
566 )
567 if unexpected_fields:
568 errors.append(
569 "brand frontmatter contains non-identity field(s): "
570 + ", ".join(unexpected_fields)
571 )
572
573 # Project pages are valid only when one sibling Layout or Deck owns them.
574 if pages and not _has_qualified_roster_spec(template_dir):
575 errors.append(
576 "brand workspaces must not contain page SVGs under templates/: "
577 + ", ".join(f"{page}.svg" for page in pages)
578 )
579
580 for numeral, title in _BRAND_REQUIRED_SECTIONS:
581 if re.search(
582 rf"^##\s+{numeral}\.\s+{re.escape(title)}\s*$",
583 body,
584 re.MULTILINE,
585 ) is None:
586 errors.append(f"missing required section: {numeral}. {title}")
587
588 for title in _BRAND_FORBIDDEN_SECTIONS:
589 if re.search(
590 rf"^##\s+(?:[IVX]+\.\s+)?{re.escape(title)}\s*$",
591 body,
592 re.MULTILINE,
593 ):
594 errors.append(f"brand scope must not declare section: {title}")
595
596 declared_primary = str(frontmatter.get("primary_color") or "").strip()
597 if _HEX_COLOR_RE.fullmatch(declared_primary) is None:
598 errors.append(
599 "frontmatter primary_color must use #RRGGBB, "
600 f"got {declared_primary!r}"
601 )
602
603 color_section = _numbered_section(body, "Color Scheme") or ""
604 primary_rows: list[str] = []
605 for line in color_section.splitlines():
606 if not line.lstrip().startswith("|"):
607 continue
608 cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
609 if len(cells) < 3:
610 continue
611 role = cells[0].strip("` ")
612 raw_color = cells[1].strip("` ")
613 if role.lower() == "role" or re.fullmatch(r":?-+:?", role):
614 continue
615 if _HEX_COLOR_RE.fullmatch(raw_color) is None:
616 errors.append(
617 f"color row {role!r} must use #RRGGBB, "
618 f"got {raw_color!r}"
619 )
620 continue
621 if role.lower() == "primary":
622 primary_rows.append(raw_color.upper())
623 provenance = cells[2].strip("` ").lower()
624 if provenance not in _BRAND_PROVENANCE_VALUES:
625 errors.append(
626 f"color {raw_color} must declare provenance as "
627 "fact, approx, or user"
628 )
629
630 if not primary_rows:
631 errors.append("Color Scheme must declare one primary color row")
632 elif len(primary_rows) > 1:
633 errors.append("Color Scheme must declare only one primary color row")
634 elif (
635 _HEX_COLOR_RE.fullmatch(declared_primary)
636 and primary_rows[0] != declared_primary.upper()
637 ):
638 errors.append(
639 "Color Scheme primary must match frontmatter primary_color: "
640 f"{primary_rows[0]} != {declared_primary.upper()}"
641 )
642
643 root = template_root.resolve()
644 for raw_ref in sorted(set(_BRAND_ASSET_REF_RE.findall(body))):
645 asset = (template_dir / raw_ref).resolve()
646 try:
647 asset.relative_to(root)
648 except ValueError:
649 errors.append(f"asset reference escapes brand workspace: {raw_ref}")
650 continue
651 if not asset.is_file():
652 errors.append(f"referenced brand asset does not exist: {raw_ref}")
653
654 if errors:
655 details = "\n".join(f" - {error}" for error in errors)
656 raise SpecParseError(f"invalid brand specification:\n{details}")
657
658
659 def _validate_style_spec(
660 expected_template_id: str | None,
661 template_root: Path,
662 template_dir: Path,
663 frontmatter: dict,
664 body: str,
665 pages: list[str],
666 ) -> None:
667 """Reject Style workspaces outside the roster-free method contract."""
668 errors: list[str] = []
669
670 raw_style_id = frontmatter.get("style_id")
671 declared_id = raw_style_id.strip() if isinstance(raw_style_id, str) else ""
672 if not declared_id:
673 errors.append("frontmatter style_id must be a non-empty string")
674 else:
675 if (
676 _PORTABLE_STYLE_ID_RE.fullmatch(declared_id) is None
677 or declared_id in {".", ".."}
678 or declared_id.endswith(".")
679 ):
680 errors.append(
681 "frontmatter style_id must be a filesystem-safe portable slug"
682 )
683 if (
684 expected_template_id is not None
685 and declared_id != expected_template_id
686 ):
687 errors.append(
688 "frontmatter style_id must match directory "
689 f"{expected_template_id!r}, got {declared_id!r}"
690 )
691
692 raw_kind = frontmatter.get("kind")
693 declared_kind = raw_kind.strip() if isinstance(raw_kind, str) else ""
694 if declared_kind != "style":
695 errors.append(
696 "frontmatter kind must be 'style', "
697 f"got {declared_kind!r}"
698 )
699
700 raw_summary = frontmatter.get("summary")
701 if not isinstance(raw_summary, str) or not _style_value_is_substantive(
702 raw_summary
703 ):
704 errors.append("frontmatter summary must be a non-empty string")
705
706 non_string_fields = [key for key in frontmatter if not isinstance(key, str)]
707 if non_string_fields:
708 errors.append("style frontmatter field names must be strings")
709 unexpected_fields = sorted(
710 key
711 for key in frontmatter
712 if isinstance(key, str) and key not in _STYLE_ALLOWED_FRONTMATTER_FIELDS
713 )
714 if unexpected_fields:
715 errors.append(
716 "style frontmatter contains unsupported field(s): "
717 + ", ".join(unexpected_fields)
718 )
719
720 keywords = frontmatter.get("keywords")
721 if (
722 not isinstance(keywords, list)
723 or not 3 <= len(keywords) <= 5
724 or not all(
725 isinstance(item, str) and _style_value_is_substantive(item)
726 for item in keywords
727 )
728 ):
729 errors.append(
730 "frontmatter keywords must contain 3-5 non-empty strings"
731 )
732 elif len({item.strip().casefold() for item in keywords}) != len(keywords):
733 errors.append("frontmatter keywords must be unique")
734
735 if pages and not _has_qualified_roster_spec(template_dir):
736 errors.append(
737 "style workspaces must not contain page SVGs without a sibling "
738 "Layout or Deck owner: "
739 + ", ".join(f"{page}.svg" for page in pages)
740 )
741
742 # The one-file packaging rule describes a workspace whose templates/ Style
743 # owns alone. A project root shares that directory with other kinds, so the
744 # rule there is only that Style itself contributes nothing but its spec.
745 if (template_dir / "design_spec.md").is_file():
746 unexpected_source_entries = sorted(
747 path.relative_to(template_dir).as_posix()
748 + ("/" if path.is_dir() else "")
749 for path in template_dir.rglob("*")
750 if path.relative_to(template_dir).as_posix() != "design_spec.md"
751 )
752 if unexpected_source_entries:
753 errors.append(
754 "style workspaces must contain only templates/design_spec.md; "
755 "unexpected template entry(s): "
756 + ", ".join(unexpected_source_entries)
757 )
758
759 if expected_template_id is not None:
760 unexpected_workspace_entries = sorted(
761 path.relative_to(template_root).as_posix()
762 + ("/" if path.is_dir() else "")
763 for path in template_root.rglob("*")
764 if path.relative_to(template_root).as_posix()
765 not in {"templates", "templates/design_spec.md"}
766 )
767 if unexpected_workspace_entries:
768 errors.append(
769 "library Style workspaces must contain only "
770 "templates/design_spec.md; unexpected workspace entry(s): "
771 + ", ".join(unexpected_workspace_entries)
772 )
773
774 h1_headings = re.findall(r"^#\s+(.+?)\s*$", body, re.MULTILINE)
775 if len(h1_headings) != 1:
776 errors.append(
777 "style body must contain exactly one document-title H1; got "
778 f"{len(h1_headings)}"
779 )
780
781 expected_headings = [
782 f"{numeral}. {title}" for numeral, title in _STYLE_REQUIRED_SECTIONS
783 ]
784 actual_headings = re.findall(r"^##\s+(.+?)\s*$", body, re.MULTILINE)
785 if actual_headings != expected_headings:
786 errors.append(
787 "style body must contain exactly the required I-VII sections in "
788 "order; got: " + (", ".join(actual_headings) or "none")
789 )
790
791 nested_headings = re.findall(
792 r"^(#{3,6})\s+(.+?)\s*$",
793 body,
794 re.MULTILINE,
795 )
796 unexpected_nested_headings = [
797 f"{marks} {title}"
798 for marks, title in nested_headings
799 if len(marks) != 3
800 or title not in {"Fallback Color Scheme", "Fallback Typography"}
801 ]
802 if unexpected_nested_headings:
803 errors.append(
804 "style body contains unsupported nested heading(s): "
805 + ", ".join(unexpected_nested_headings)
806 )
807 allowed_h3 = [title for marks, title in nested_headings if len(marks) == 3]
808 if len(allowed_h3) != len(set(allowed_h3)):
809 errors.append("style fallback subsections must not be repeated")
810
811 for title in _STYLE_FORBIDDEN_SECTIONS:
812 if re.search(
813 rf"^#{{1,6}}\s+(?:[IVX]+\.\s+)?{re.escape(title)}\s*$",
814 body,
815 re.MULTILINE,
816 ):
817 errors.append(f"style scope must not declare section: {title}")
818
819 for section_title, labels in _STYLE_REQUIRED_FIELDS.items():
820 section_name = section_title.split(". ", 1)[1]
821 if _numbered_section(body, section_name) is None:
822 continue
823 for label in labels:
824 value = _extract_section_field(body, section_title, [label])
825 if not _style_value_is_substantive(value):
826 errors.append(
827 f"{section_title} must declare a non-empty {label} field"
828 )
829
830 role_section = _numbered_section(body, "Page Role Vocabulary") or ""
831 role_rows = [
832 row
833 for row in _markdown_table_rows(role_section)
834 if row and row[0].casefold() != "role"
835 ]
836 if not any(
837 len(row) >= 4
838 and all(_style_value_is_substantive(cell) for cell in row[:4])
839 for row in role_rows
840 ):
841 errors.append(
842 "III. Page Role Vocabulary must contain at least one complete "
843 "four-column role row"
844 )
845
846 for (
847 section_title,
848 preferred_label,
849 behavior_label,
850 references_label,
851 catalog_dir,
852 ) in _STYLE_CUSTOM_FIELDS:
853 preferred = _extract_section_field(
854 body,
855 section_title,
856 [preferred_label],
857 )
858 behavior = _extract_section_field(
859 body,
860 section_title,
861 [behavior_label],
862 )
863 references = _extract_section_field(
864 body,
865 section_title,
866 [references_label],
867 )
868 preferred_text = _clean_field_value(preferred or "")
869 catalog_ids = {
870 path.stem
871 for path in catalog_dir.glob("*.md")
872 if not path.stem.startswith("_")
873 }
874 is_custom = bool(
875 re.match(r"^custom(?:\b|\s*[:—-])", preferred_text, re.IGNORECASE)
876 )
877 if is_custom and not _style_value_is_substantive(behavior):
878 errors.append(
879 f"{behavior_label} is required when {preferred_label} is custom"
880 )
881 if not is_custom and _style_value_is_substantive(behavior):
882 errors.append(
883 f"{behavior_label} is allowed only when {preferred_label} is custom"
884 )
885 if not is_custom and _style_value_is_substantive(references):
886 errors.append(
887 f"{references_label} is allowed only when "
888 f"{preferred_label} is custom"
889 )
890 if (
891 _style_value_is_substantive(preferred)
892 and not is_custom
893 and preferred_text not in catalog_ids
894 ):
895 errors.append(
896 f"{preferred_label} references unknown catalog id "
897 f"{preferred_text!r}"
898 )
899 if is_custom and _style_value_is_substantive(references):
900 catalog_references = [
901 _clean_field_value(item)
902 for item in (references or "").split(",")
903 ]
904 if any(not item for item in catalog_references):
905 errors.append(
906 f"{references_label} must be a comma-separated list of "
907 "catalog ids"
908 )
909 duplicates = sorted(
910 item
911 for item in set(catalog_references)
912 if catalog_references.count(item) > 1
913 )
914 if duplicates:
915 errors.append(
916 f"{references_label} repeats catalog id(s): "
917 + ", ".join(duplicates)
918 )
919 unknown_references = sorted(
920 item
921 for item in set(catalog_references)
922 if item == "custom" or item not in catalog_ids
923 )
924 if unknown_references:
925 errors.append(
926 f"{references_label} references unknown catalog id(s): "
927 + ", ".join(unknown_references)
928 )
929
930 visual_section = _numbered_section(body, "Visual System Defaults") or ""
931 fallback_colors = _markdown_subsection(body, "Fallback Color Scheme")
932 if fallback_colors is not None:
933 if "### Fallback Color Scheme" not in visual_section:
934 errors.append("Fallback Color Scheme must appear under section V")
935 color_rows = [
936 row
937 for row in _markdown_table_rows(fallback_colors)
938 if row and row[0].casefold() != "role"
939 ]
940 if not color_rows:
941 errors.append("Fallback Color Scheme must contain at least one row")
942 for row in color_rows:
943 if (
944 len(row) < 3
945 or not _style_value_is_substantive(row[0])
946 or _HEX_COLOR_RE.fullmatch(row[1].strip("` ")) is None
947 or not _style_value_is_substantive(row[2])
948 ):
949 errors.append(
950 "Fallback Color Scheme rows must be Role | #RRGGBB | Purpose"
951 )
952 break
953
954 fallback_typography = _markdown_subsection(body, "Fallback Typography")
955 if fallback_typography is not None:
956 if "### Fallback Typography" not in visual_section:
957 errors.append("Fallback Typography must appear under section V")
958 typography_rows = [
959 row
960 for row in _markdown_table_rows(fallback_typography)
961 if row and row[0].casefold() != "role"
962 ]
963 if not any(
964 len(row) >= 4
965 and all(_style_value_is_substantive(cell) for cell in row[:4])
966 for row in typography_rows
967 ):
968 errors.append(
969 "Fallback Typography must contain at least one complete "
970 "four-column row"
971 )
972
973 review_section = _numbered_section(body, "Review Focus") or ""
974 if review_section.count(_STYLE_REVIEW_TRIGGER_MARKER) != 1:
975 errors.append(
976 "VII. Review Focus must contain exactly one "
977 f"{_STYLE_REVIEW_TRIGGER_MARKER} marker"
978 )
979 review_items = re.findall(r"^[-*]\s+(.+?)\s*$", review_section, re.MULTILINE)
980 if not any(_style_value_is_substantive(item) for item in review_items):
981 errors.append("VII. Review Focus must contain at least one check")
982
983 for label in _STYLE_FORBIDDEN_FIELDS:
984 if _style_field_is_declared(body, label):
985 errors.append(f"style scope must not declare field: {label}")
986
987 if errors:
988 details = "\n".join(f" - {error}" for error in errors)
989 raise SpecParseError(f"invalid style specification:\n{details}")
990
991
992 def _validate_svg_template_spec(
993 kind: str,
994 template_id: str,
995 template_dir: Path,
996 frontmatter: dict,
997 pages: list[str],
998 *,
999 validate_payload: bool = True,
1000 ) -> None:
1001 """Validate Layout/Deck metadata and, when active, its SVG payload."""
1002 errors: list[str] = []
1003 id_key = KIND_CONFIG[kind]["id_key"]
1004 declared_id = str(frontmatter.get(id_key) or "").strip()
1005 if declared_id != template_id:
1006 errors.append(
1007 f"frontmatter {id_key} must match directory {template_id!r}, "
1008 f"got {declared_id!r}"
1009 )
1010 declared_kind = str(frontmatter.get("kind") or "").strip()
1011 if declared_kind != kind:
1012 errors.append(
1013 f"frontmatter kind must be {kind!r}, got {declared_kind!r}"
1014 )
1015 if not str(frontmatter.get("summary") or "").strip():
1016 errors.append("frontmatter summary must be non-empty")
1017 if not pages:
1018 errors.append(f"{kind} workspace must contain at least one template SVG")
1019
1020 raw_page_count = frontmatter.get("page_count")
1021 if isinstance(raw_page_count, bool) or not isinstance(raw_page_count, int):
1022 errors.append("frontmatter page_count must be an integer")
1023 elif raw_page_count != len(pages):
1024 errors.append(
1025 f"frontmatter page_count is {raw_page_count}, but templates/ "
1026 f"contains {len(pages)} SVG files"
1027 )
1028
1029 canvas_format = str(frontmatter.get("canvas_format") or "").strip()
1030 canvas = CANVAS_FORMATS.get(canvas_format)
1031 if canvas is None:
1032 errors.append(
1033 "frontmatter canvas_format must be one of: "
1034 + ", ".join(sorted(CANVAS_FORMATS))
1035 )
1036 else:
1037 expected_canvas_fields = {
1038 "canvas_width": canvas["width"],
1039 "canvas_height": canvas["height"],
1040 "canvas_viewbox": canvas["viewbox"],
1041 }
1042 for field, expected in expected_canvas_fields.items():
1043 actual = frontmatter.get(field)
1044 if str(actual) != str(expected):
1045 errors.append(
1046 f"frontmatter {field} must be {expected!r} for "
1047 f"{canvas_format}, got {actual!r}"
1048 )
1049
1050 if frontmatter.get("native_structure_mode") != "structured":
1051 errors.append(
1052 "frontmatter native_structure_mode must be 'structured'"
1053 )
1054 if frontmatter.get("replication_mode") not in {
1055 "standard",
1056 "fidelity",
1057 "mirror",
1058 }:
1059 errors.append(
1060 "frontmatter replication_mode must be standard, fidelity, or mirror"
1061 )
1062
1063 if kind == "layout":
1064 raw_page_types = frontmatter.get("page_types")
1065 expected_page_types = _derive_page_types(pages)
1066 if not isinstance(raw_page_types, list) or not all(
1067 isinstance(item, str) and item.strip()
1068 for item in raw_page_types
1069 ):
1070 errors.append("frontmatter page_types must be a non-empty string list")
1071 elif raw_page_types != expected_page_types:
1072 errors.append(
1073 "frontmatter page_types must exactly match the SVG filename "
1074 f"roster: expected {expected_page_types!r}, got {raw_page_types!r}"
1075 )
1076 else:
1077 primary_color = str(frontmatter.get("primary_color") or "").strip()
1078 if _HEX_COLOR_RE.fullmatch(primary_color) is None:
1079 errors.append(
1080 "frontmatter primary_color must use #RRGGBB, "
1081 f"got {primary_color!r}"
1082 )
1083
1084 svg_paths = [template_dir / f"{page}.svg" for page in pages]
1085 if validate_payload and canvas is not None:
1086 expected_viewbox = str(canvas["viewbox"])
1087 for svg_path in svg_paths:
1088 try:
1089 root = ET.parse(svg_path).getroot()
1090 except (OSError, ET.ParseError) as exc:
1091 errors.append(f"{svg_path.name} is not valid SVG XML: {exc}")
1092 continue
1093 actual_canvas = (
1094 root.get("width"),
1095 root.get("height"),
1096 root.get("viewBox"),
1097 )
1098 expected_canvas = (
1099 str(canvas["width"]),
1100 str(canvas["height"]),
1101 expected_viewbox,
1102 )
1103 if actual_canvas != expected_canvas:
1104 errors.append(
1105 f"{svg_path.name} canvas is {actual_canvas!r}, expected "
1106 f"{expected_canvas!r}"
1107 )
1108
1109 if validate_payload and svg_paths:
1110 try:
1111 from svg_to_pptx.pptx_package.template_structure import (
1112 TemplateStructureError,
1113 parse_template_slides,
1114 )
1115 except ImportError as exc:
1116 errors.append(f"structured SVG roster is invalid: {exc}")
1117 else:
1118 try:
1119 parse_template_slides(svg_paths)
1120 except TemplateStructureError as exc:
1121 errors.append(f"structured SVG roster is invalid: {exc}")
1122
1123 if errors:
1124 details = "\n".join(f" - {error}" for error in errors)
1125 raise SpecParseError(f"invalid {kind} specification:\n{details}")
1126
1127
1128 def validate_shadowed_deck_spec(
1129 spec_path: str | Path,
1130 declared_pages: list[str],
1131 ) -> None:
1132 """Validate a Deck spec whose SVG roster is overridden by Layout."""
1133 path = Path(spec_path)
1134 match = _SPEC_NAME_RE.fullmatch(path.name)
1135 if match is None or match.group("kind") != "deck":
1136 raise SpecParseError(
1137 "shadowed Deck validation requires design_spec.deck.<id>.md"
1138 )
1139 _kind, filename_id, frontmatter, _body = validate_qualified_spec_identity(
1140 path
1141 )
1142 _validate_svg_template_spec(
1143 "deck",
1144 filename_id,
1145 path.parent,
1146 frontmatter,
1147 declared_pages,
1148 validate_payload=False,
1149 )
1150
1151
1152 # ---------------------------------------------------------------------------
1153 # Per-kind extraction
1154 # ---------------------------------------------------------------------------
1155
1156 def _extract_entry(
1157 kind: str,
1158 template_id: str | None,
1159 template_dir: Path,
1160 ) -> dict:
1161 """Build the index entry + extras for a single template."""
1162 template_root = template_dir
1163 template_dir = _template_content_dir(template_root)
1164 if kind == "style" and template_dir == template_root:
1165 raise SpecParseError(
1166 "Style workspaces require templates/design_spec.md; "
1167 "legacy-flat design_spec.md is not supported"
1168 )
1169 if template_id is not None and template_dir != template_root:
1170 exact_spec = template_dir / "design_spec.md"
1171 if not exact_spec.is_file():
1172 raise SpecParseError(
1173 "library workspaces require templates/design_spec.md; "
1174 "kind-qualified specs belong only to shared project roots"
1175 )
1176 spec_path = _resolve_spec_path(template_dir, kind)
1177
1178 frontmatter, body = _read_spec(spec_path)
1179 fm = frontmatter or {}
1180
1181 declared_kind = fm.get("kind")
1182 if declared_kind not in (None, kind):
1183 raise SpecParseError(
1184 f"design_spec.md frontmatter declares kind={declared_kind!r}; "
1185 f"expected kind={kind!r} — use --kind {declared_kind} instead"
1186 )
1187
1188 raw_summary = fm.get("summary")
1189 summary = raw_summary.strip() if isinstance(raw_summary, str) else ""
1190 if not summary:
1191 section_title = (
1192 "I. Brand Overview" if kind == "brand" else "I. Template Overview"
1193 )
1194 summary = (_summary_from_use_cases(
1195 _extract_section_field(body, section_title, ["Use Cases", "Use cases"])
1196 ) or "").strip()
1197
1198 pages = _list_pages(template_dir)
1199 primary_color = fm.get("primary_color") or _extract_primary_color(body) or ""
1200 resolved_template_id = (
1201 template_id
1202 or str(fm.get(KIND_CONFIG[kind]["id_key"]) or "").strip()
1203 or template_root.name
1204 )
1205
1206 if kind == "brand":
1207 _validate_brand_spec(
1208 template_id,
1209 template_root,
1210 template_dir,
1211 fm,
1212 body,
1213 pages,
1214 )
1215 entry = OrderedDict(
1216 summary=summary,
1217 primary_color=str(primary_color),
1218 )
1219 elif kind == "style":
1220 _validate_style_spec(
1221 template_id,
1222 template_root,
1223 template_dir,
1224 fm,
1225 body,
1226 pages,
1227 )
1228 entry = OrderedDict(
1229 summary=summary,
1230 keywords=[item.strip() for item in fm["keywords"]],
1231 )
1232 elif kind == "layout":
1233 if template_id is None:
1234 raise SpecParseError("layout validation requires an expected layout_id")
1235 _validate_svg_template_spec(
1236 kind,
1237 template_id,
1238 template_dir,
1239 fm,
1240 pages,
1241 )
1242 page_types = fm["page_types"]
1243 entry = OrderedDict(
1244 summary=summary,
1245 canvas_format=str(fm["canvas_format"]),
1246 page_count=int(fm["page_count"]),
1247 page_types=list(page_types),
1248 )
1249 elif kind == "deck":
1250 if template_id is None:
1251 raise SpecParseError("deck validation requires an expected deck_id")
1252 _validate_svg_template_spec(
1253 kind,
1254 template_id,
1255 template_dir,
1256 fm,
1257 pages,
1258 )
1259 entry = OrderedDict(
1260 summary=summary,
1261 canvas_format=str(fm["canvas_format"]),
1262 page_count=int(fm["page_count"]),
1263 primary_color=str(primary_color),
1264 )
1265 else:
1266 raise SpecParseError(f"unknown kind {kind!r}")
1267
1268 extras = OrderedDict(
1269 pages=pages,
1270 primary_color=str(primary_color),
1271 page_prefix="templates/" if template_dir != template_root else "",
1272 preview=(
1273 f"exports/{resolved_template_id}_template_preview.pptx"
1274 if (
1275 template_root
1276 / "exports"
1277 / f"{resolved_template_id}_template_preview.pptx"
1278 ).is_file()
1279 else ""
1280 ),
1281 )
1282 return {"entry": entry, "extras": extras}
1283
1284
1285 def _resolve_spec_path(template_dir: Path, kind: str) -> Path:
1286 """Return the Design Spec one kind owns inside a template source directory.
1287
1288 A library workspace keeps the exact ``design_spec.md`` because
1289 ``<kind_dir>/<template_id>/`` already names its kind and id. A project
1290 workspace root has no such parent, so it keeps
1291 ``design_spec.<kind>.<id>.md`` and may hold one spec per kind side by side.
1292 """
1293 qualified = _validate_spec_shape(template_dir)
1294 exact = template_dir / "design_spec.md"
1295 if exact.is_file():
1296 return exact
1297 matches = sorted(
1298 path for path, declared_kind in qualified if declared_kind == kind
1299 )
1300 if len(matches) == 1:
1301 return matches[0]
1302 if not matches:
1303 raise SpecParseError(
1304 f"missing design_spec.md or design_spec.{kind}.<id>.md in {template_dir}"
1305 )
1306 raise SpecParseError(
1307 f"{template_dir} declares kind {kind!r} more than once: "
1308 + ", ".join(path.name for path in matches)
1309 )
1310
1311
1312 def validate_brand_workspace(template_root: str | Path) -> dict:
1313 """Validate a portable Brand workspace without registering it.
1314
1315 This is the project-scope entry used by ``svg_quality_checker.py
1316 --template-mode``. Library registration calls the same extraction path with
1317 an expected directory id, so both scopes share one Brand schema authority.
1318 """
1319 return _extract_entry("brand", None, Path(template_root))
1320
1321
1322 def validate_style_workspace(template_root: str | Path) -> dict:
1323 """Validate a portable Style workspace without registering it.
1324
1325 Global library roots also enforce directory identity and the one-file
1326 package boundary. Project roots keep their unrelated initialized-project
1327 scaffolding out of the Style contract.
1328 """
1329 root = Path(template_root)
1330 expected_id = (
1331 root.name
1332 if root.resolve().parent == KIND_CONFIG["style"]["dir"].resolve()
1333 else None
1334 )
1335 return _extract_entry("style", expected_id, root)
1336
1337
1338 # ---------------------------------------------------------------------------
1339 # Index / README writers
1340 # ---------------------------------------------------------------------------
1341
1342 def _load_index(path: Path) -> "OrderedDict[str, dict]":
1343 if not path.exists():
1344 return OrderedDict()
1345 raw_text = path.read_text(encoding="utf-8").strip() or "{}"
1346 raw = json.loads(raw_text)
1347 return OrderedDict(sorted(raw.items()))
1348
1349
1350 def _write_index(path: Path, data: "OrderedDict[str, dict]", *, dry_run: bool) -> None:
1351 payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
1352 if dry_run:
1353 print(f"--- {path.name} (dry-run) ---")
1354 print(payload)
1355 return
1356 path.parent.mkdir(parents=True, exist_ok=True)
1357 path.write_text(payload, encoding="utf-8")
1358
1359
1360 def _enumerate_ids(kind: str) -> list[str]:
1361 base = KIND_CONFIG[kind]["dir"]
1362 if not base.exists():
1363 return []
1364 return sorted(
1365 p.name for p in base.iterdir()
1366 if p.is_dir()
1367 and (
1368 (p / "templates" / "design_spec.md").is_file()
1369 or (p / "design_spec.md").is_file()
1370 )
1371 )
1372
1373
1374 def _print_completion_card(kind: str, template_id: str, entry: dict, extras: dict) -> None:
1375 pretty_kind = {
1376 "layout": "Layout",
1377 "deck": "Deck",
1378 "brand": "Brand",
1379 "style": "Style",
1380 }[kind]
1381 dir_name = {
1382 "layout": "layouts",
1383 "deck": "decks",
1384 "brand": "brands",
1385 "style": "styles",
1386 }[kind]
1387 print()
1388 print(f"## {pretty_kind} Registration Complete")
1389 print()
1390 print(f"**{pretty_kind} ID**: {template_id}")
1391 print(f"**Path**: `templates/{dir_name}/{template_id}/`")
1392 if kind in ("brand", "deck"):
1393 primary = entry.get("primary_color") or "—"
1394 print(f"**Primary Color**: {primary}")
1395 if kind in ("layout", "deck"):
1396 canvas = entry.get("canvas_format") or "—"
1397 pc = entry.get("page_count") or "—"
1398 print(f"**Canvas**: {canvas}")
1399 print(f"**Pages**: {pc}")
1400 print(f"**Summary**: {entry.get('summary') or '—'}")
1401 print("**Index Registration**: Done")
1402 print()
1403 if KIND_CONFIG[kind]["needs_svg_roster"]:
1404 pages = extras.get("pages") or []
1405 page_prefix = extras.get("page_prefix") or ""
1406 preview = extras.get("preview") or ""
1407 if preview:
1408 print(f"**Review PPTX**: `{preview}`")
1409 print()
1410 if pages:
1411 print("### Files Included")
1412 print()
1413 print("| File | Status |")
1414 print("|------|--------|")
1415 for page in pages:
1416 print(f"| `{page_prefix}{page}.svg` | Done |")
1417 if preview:
1418 print(f"| `{preview}` | Verified |")
1419 print()
1420
1421
1422 # ---------------------------------------------------------------------------
1423 # Main
1424 # ---------------------------------------------------------------------------
1425
1426 def main() -> int:
1427 require_skill_integrity()
1428 parser = argparse.ArgumentParser(
1429 description=(
1430 "Register / refresh templates (brand / style / layout / deck) "
1431 "in the index."
1432 )
1433 )
1434 parser.add_argument(
1435 "template_id", nargs="?",
1436 help="Template directory id (under templates/<kind_dir>/). Omit with --rebuild-all.",
1437 )
1438 parser.add_argument(
1439 "--kind", choices=list(KIND_CONFIG.keys()), default="deck",
1440 help="Template kind (default: deck).",
1441 )
1442 parser.add_argument("--rebuild-all", action="store_true",
1443 help="Rebuild every index entry within the chosen kind.")
1444 parser.add_argument("--dry-run", action="store_true",
1445 help="Show what would be written without modifying any files.")
1446 args = parser.parse_args()
1447
1448 if not args.template_id and not args.rebuild_all:
1449 parser.error("provide a template_id or use --rebuild-all")
1450
1451 cfg = KIND_CONFIG[args.kind]
1452 base = cfg["dir"]
1453
1454 if args.rebuild_all:
1455 ids = _enumerate_ids(args.kind)
1456 if not ids:
1457 print(f"[OK] No {args.kind} directories found; index left empty.")
1458 _write_index(cfg["index"], OrderedDict(), dry_run=args.dry_run)
1459 return 0
1460 else:
1461 ids = [args.template_id]
1462 spec_dir = base / args.template_id
1463 if not spec_dir.is_dir():
1464 print(f"Error: {args.kind} directory not found: {spec_dir}", file=sys.stderr)
1465 return 1
1466
1467 extracted: dict[str, dict] = {}
1468 for tid in ids:
1469 try:
1470 extracted[tid] = _extract_entry(args.kind, tid, base / tid)
1471 except SpecParseError as exc:
1472 print(f"Error: {tid}: {exc}", file=sys.stderr)
1473 return 1
1474
1475 if args.rebuild_all:
1476 index = OrderedDict((tid, extracted[tid]["entry"]) for tid in sorted(extracted))
1477 else:
1478 index = _load_index(cfg["index"])
1479 for tid, payload in extracted.items():
1480 index[tid] = payload["entry"]
1481 index = OrderedDict(sorted(index.items()))
1482
1483 _write_index(cfg["index"], index, dry_run=args.dry_run)
1484
1485 if not args.dry_run and not args.rebuild_all:
1486 tid = args.template_id
1487 _print_completion_card(
1488 args.kind, tid, extracted[tid]["entry"], extracted[tid]["extras"]
1489 )
1490 return 0
1491
1492 print()
1493 print(
1494 f"[OK] {'Dry-run preview' if args.dry_run else 'Updated'}: "
1495 f"{len(extracted)} {args.kind}(s) processed; index now lists {len(index)} entries."
1496 )
1497 return 0
1498
1499
1500 if __name__ == "__main__":
1501 sys.exit(main())
1502
1502 lines PYTHON