"""Generate catalog wrappers and self-contained offline pages (Python stdlib only).""" from pathlib import Path import argparse import hashlib import html import json import re root = Path(__file__).resolve().parents[1] source = root / 'assets/animejs' vendor = root / 'assets/vendor/anime.umd.min.js' VERSION = '0.7.0' BASELINE = { 'oil-gas': ['separator-upset', 'pipeline-reroute'], 'pharma': ['batch-genealogy', 'cold-chain-quarantine'], 'government': ['permit-review-loop', 'budget-execution'], 'retail': ['inventory-reservation', 'return-disposition'], 'logistics': ['hub-connection-recovery', 'port-crane-yard'], 'manufacturing': ['takt-buffer', 'fault-rework'], 'energy': ['microgrid-storage', 'feeder-restoration'], 'healthcare': ['care-handoffs', 'sample-rejection'], 'finance': ['payment-reconciliation', 'insurance-evidence'], 'telecom': ['congestion-reroute', 'cell-handover'], 'agriculture': ['irrigation-zones', 'harvest-grading'], 'infrastructure': ['bridge-inspection', 'construction-resequence'], 'presentation-patterns': ['expressive-robot', 'approval-gate', 'delivery-cycle', 'connected-memory'], } SECTORS = list(BASELINE) parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--allow-partial', action='store_true', help='Development only: build available modules and record missing groups') parser.add_argument('--require-complete', action='store_true', help='Explicit release gate (the default)') args = parser.parse_args() def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest() entries = [e for e in json.loads((root / 'vendor-manifest.json').read_text()) if e['path'] == 'assets/vendor/anime.umd.min.js'] if len(entries) != 1 or digest(vendor) != entries[0]['sha256']: raise ValueError('Missing, duplicate or mismatched Anime vendor fingerprint') # examples.js deliberately contains a JSON array, preserving all five legacy configs. legacy_text = (source / 'examples.js').read_text() legacy = json.loads(legacy_text[legacy_text.index('['):legacy_text.rindex(']') + 1]) # Legacy beat positions belong to the mounted recipe, never the Python builder. legacy_ids = ['svg-draw', 'motion-path', 'spatial-stagger', 'dependency-flow', 'assemble-explode'] for c in legacy: c['group'] = 'elements' if c['kind'] == 'element' else 'compositions' c['groupTitle'] = c['group'].title() c['beatSource'] = 'runtime' c['useWhen'] = 'Explain ' + c['description'][0].lower() + c['description'][1:] c['avoid'] = 'Avoid decorative motion or treating illustrative timing and geometry as measured performance.' c['assumptions'] = 'Original conceptual example; all timing and geometry are illustrative. Retest edited parameters.' records = list(legacy) missing = [] for group in SECTORS + sorted(p.stem for p in (source / 'catalogs').glob('*.json') if p.stem not in SECTORS): if not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', group): raise ValueError('Invalid group slug: ' + group) data = source / 'catalogs' / (group + '.json') module = source / 'industries' / (group + '.js') if not data.exists() or not module.exists(): missing.append(group) continue sector = json.loads(data.read_text()) if not isinstance(sector, list) or not sector: raise ValueError(f'{group}: expected a nonempty descriptor list') for c in sector: for field in ['id', 'group', 'groupTitle', 'title', 'description', 'useWhen', 'avoid', 'assumptions']: if not isinstance(c.get(field), str) or not c[field].strip(): raise ValueError(f'{group}: missing string {field}') if c['group'] != group or not re.fullmatch(re.escape(group) + r'-[a-z0-9-]+', c['id']): raise ValueError(f'{group}: invalid group/id') if not isinstance(c.get('config'), dict) or not isinstance(c.get('beats'), list) or not c['beats']: raise ValueError(f'{group}: missing config/beats') previous = -1 for beat in c['beats']: at = beat.get('at') if not isinstance(at, (int, float)) or not 0 <= at < float('inf') or at < previous or not isinstance(beat.get('label'), str): raise ValueError(f'{group}: invalid beat') previous = at c['kind'] = 'industry' if len({c['groupTitle'] for c in sector}) != 1: raise ValueError(f'{group}: inconsistent groupTitle') records.extend(sector) required_ids = legacy_ids + [group + '-' + scenario for group, scenarios in BASELINE.items() for scenario in scenarios] missing_ids = [id for id in required_ids if id not in {c['id'] for c in records}] if (missing or missing_ids) and (args.require_complete or not args.allow_partial): raise ValueError('Incomplete release baseline. Missing groups: ' + ', '.join(missing) + '; missing IDs: ' + ', '.join(missing_ids)) if len({c['id'] for c in records}) != len(records): raise ValueError('Duplicate catalog IDs') groups = list(dict.fromkeys(c['group'] for c in records)) # Static script assignment works from file:// without ESM, fetch or a server. (source / 'catalog-data.js').write_text('/* Generated by scripts/build-gallery.py; edit catalogs or examples.js. */\nwindow.animeExamples = ' + json.dumps(records, ensure_ascii=True, indent=2) + ';\n') license_text = html.escape((root / 'assets/vendor/anime-LICENSE.md').read_text()) esc = html.escape pages = {} def page(rel, title, body_attr, nav, description, modules=()): prefix = '../' if '/' in rel else '' scripts = [] if modules: scripts.append(prefix + '../vendor/anime.umd.min.js') scripts.append(prefix + 'catalog-data.js') scripts.extend(prefix + m for m in modules if m) scripts.append(prefix + 'gallery.js') text = f''' {esc(title)} — Motion that explains

{esc(title)}

{esc(description)}

''' + ''.join(f'' for s in scripts) + '\n' pages[rel] = text page('index.html', 'Motion that explains', 'data-gallery="all"', 'All examples', f'{len(records)} editable examples across {len(groups)} registries. Choose a question or industry; inspect meaningful timeline beats. All scenes use illustrative models.') for group in groups: title = next(c['groupTitle'] for c in records if c['group'] == group) page(group + '/index.html', title, f'data-gallery="{group}"', f'All examples / {esc(title)}', 'Open an example for use/avoid guidance, assumptions and named timeline beats.') for c in records: nav = f'All examples / {esc(c["groupTitle"])} / {esc(c["title"])}' modules = ['industries/' + c['group'] + '.js'] if c['kind'] == 'industry' else [''] page('examples/' + c['id'] + '.html', c['title'], f'data-example="{c["id"]}"', nav, 'Scenes start complete. Play or replay; seek a named beat to inspect a phase. Reduced motion keeps the scene complete.', modules) out = root / ('dist/animejs-gallery-' + VERSION) files = {} for rel, text in pages.items(): original = source / rel original.parent.mkdir(parents=True, exist_ok=True) original.write_text(text, encoding='utf-8') def style(match): return '' def script(match): js = re.sub(r'//# sourceMappingURL=.*', '', (original.parent / match[1]).read_text()) return '' text = re.sub(r'', style, text) text = re.sub(r'', script, text) target = out / rel target.parent.mkdir(parents=True, exist_ok=True) target.write_text(text, encoding='utf-8') files[rel] = digest(target) inputs = [*sorted(source.rglob('*')), vendor, root / 'assets/vendor/anime-LICENSE.md', Path(__file__)] report = {'package': out.name, 'entry': 'index.html', 'anime': '4.5.0', 'examples': len(records), 'groups': groups, 'ids': [c['id'] for c in records], 'missingGroups': missing, 'complete': not missing and not missing_ids, 'requiredGroups': SECTORS, 'requiredIds': required_ids, 'missingIds': missing_ids, 'files': files, 'inputs': {str(p.relative_to(root)): digest(p) for p in inputs if p.is_file()}} (root / 'dist/animejs-gallery-manifest.json').write_text(json.dumps(report, indent=2) + '\n') print(json.dumps({k: report[k] for k in ['package', 'examples', 'groups', 'complete', 'missingGroups']})) print(f'Built {len(files)} HTML pages; previous dist versions preserved.')