140 lines
9.0 KiB
Python
140 lines
9.0 KiB
Python
"""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.6.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'],
|
|
}
|
|
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'''<!doctype html>
|
|
<!-- Generated from catalog metadata by scripts/build-gallery.py. -->
|
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{esc(title)} — Motion that explains</title><link rel="stylesheet" href="{prefix}gallery.css"></head>
|
|
<body {body_attr}><header><nav aria-label="Breadcrumb">{nav}</nav><h1>{esc(title)}</h1><p>{esc(description)}</p></header><main aria-label="Animation examples"></main><footer><p>Original conceptual examples · Anime.js 4.5.0 · Offline library {VERSION}. Not operational, clinical or engineering advice.</p><details><summary>Anime.js license</summary><pre>{license_text}</pre></details></footer>
|
|
''' + ''.join(f'<script src="{s}"></script>' for s in scripts) + '</body></html>\n'
|
|
pages[rel] = text
|
|
page('index.html', 'Motion that explains', 'data-gallery="all"', '<span aria-current="page">All examples</span>', 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'<a href="../index.html">All examples</a> / <span aria-current="page">{esc(title)}</span>', 'Open an example for use/avoid guidance, assumptions and named timeline beats.')
|
|
for c in records:
|
|
nav = f'<a href="../index.html">All examples</a> / <a href="../{c["group"]}/index.html">{esc(c["groupTitle"])}</a> / <span aria-current="page">{esc(c["title"])}</span>'
|
|
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 '<style>' + (original.parent / match[1]).read_text() + '</style>'
|
|
def script(match):
|
|
js = re.sub(r'//# sourceMappingURL=.*', '', (original.parent / match[1]).read_text())
|
|
return '<script>' + js.replace('</script', r'<\/script') + '</script>'
|
|
text = re.sub(r'<link rel="stylesheet" href="([^"]+)">', style, text)
|
|
text = re.sub(r'<script src="([^"]+)"></script>', 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.')
|