motionspec 1.0.0

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.
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "counterUp",
3
+ "version": "1.0.0",
4
+ "purpose": "Zahl zaehlt beim Einblenden in den Viewport von 0 bis zum Zielwert hoch.",
5
+ "output": "js",
6
+ "engine": "gsap.ScrollTrigger",
7
+ "paramSchema": {
8
+ "duration": {
9
+ "type": "number",
10
+ "default": 1.6,
11
+ "min": 0.2,
12
+ "max": 5
13
+ },
14
+ "ease": {
15
+ "type": "string",
16
+ "default": "power1.out",
17
+ "pattern": "^[A-Za-z0-9.()]{1,40}$"
18
+ },
19
+ "step": {
20
+ "type": "number",
21
+ "default": 1,
22
+ "min": 0.01,
23
+ "max": 1000
24
+ },
25
+ "locale": {
26
+ "type": "string",
27
+ "default": "de-DE",
28
+ "pattern": "^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$"
29
+ }
30
+ },
31
+ "triggerDefaults": {
32
+ "start": "top 85%",
33
+ "once": true
34
+ },
35
+ "performance": {
36
+ "verified": true,
37
+ "lcpSafe": true,
38
+ "cost": 1,
39
+ "verifiedAt": "2026-06-12"
40
+ },
41
+ "a11y": {
42
+ "reducedMotionFallback": "instant-visible"
43
+ },
44
+ "template": "document.querySelectorAll({{target}}).forEach(function(el){ var end=parseFloat(el.getAttribute('data-count')||el.textContent); if(!isFinite(end)) end=0; var o={v:0}; gsap.to(o,{v:end,duration:{{params.duration}},ease:{{params.ease}},snap:{v:{{params.step}}},onUpdate:function(){el.textContent=o.v.toLocaleString({{params.locale}})},scrollTrigger:{trigger:el,start:{{trigger.start}},once:{{trigger.once}}}}); });",
45
+ "demo": {
46
+ "html": "<p class=\"counter d-counterUp\" data-count=\"12847\">12.847</p>\n<p class=\"counter d-counterUp\" data-count=\"99\">99</p>",
47
+ "params": {
48
+ "duration": 2,
49
+ "ease": "power2.out",
50
+ "step": 1,
51
+ "locale": "de-DE"
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "cssTransition",
3
+ "version": "1.1.0",
4
+ "purpose": "Einfache Hover-Mikrointeraktion ganz ohne JavaScript.",
5
+ "output": "css",
6
+ "engine": "native-css",
7
+ "paramSchema": {
8
+ "property": {
9
+ "type": "string",
10
+ "default": "transform"
11
+ },
12
+ "hoverValue": {
13
+ "type": "string",
14
+ "default": "translateY(-4px)"
15
+ },
16
+ "duration": {
17
+ "type": "number",
18
+ "default": 0.25,
19
+ "min": 0.05,
20
+ "max": 1
21
+ },
22
+ "easing": {
23
+ "type": "string",
24
+ "default": "ease-out"
25
+ }
26
+ },
27
+ "triggerDefaults": {},
28
+ "performance": {
29
+ "verified": true,
30
+ "lcpSafe": true,
31
+ "cost": 0,
32
+ "verifiedAt": "2026-06-12"
33
+ },
34
+ "a11y": {
35
+ "reducedMotionFallback": "none-needed"
36
+ },
37
+ "template": "{{css target}} { transition: {{css params.property}} {{css params.duration}}s {{css params.easing}}; }\n{{css target}}:hover { {{css params.property}}: {{css params.hoverValue}}; }"
38
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "marquee",
3
+ "version": "1.0.0",
4
+ "purpose": "Endlos laufendes horizontales Laufband — Inhalt als ZWEI identische Gruppen anlegen (nahtlose Schleife).",
5
+ "output": "css",
6
+ "engine": "native-css",
7
+ "paramSchema": {
8
+ "duration": {
9
+ "type": "number",
10
+ "default": 24,
11
+ "min": 4,
12
+ "max": 120
13
+ },
14
+ "gap": {
15
+ "type": "string",
16
+ "default": "3rem",
17
+ "pattern": "^[0-9]*\\.?[0-9]+(px|rem|em|vw|ch|%)$"
18
+ },
19
+ "direction": {
20
+ "type": "string",
21
+ "default": "normal",
22
+ "pattern": "^(normal|reverse)$"
23
+ }
24
+ },
25
+ "triggerDefaults": {},
26
+ "performance": {
27
+ "verified": true,
28
+ "lcpSafe": true,
29
+ "cost": 1,
30
+ "verifiedAt": "2026-06-12"
31
+ },
32
+ "a11y": {
33
+ "reducedMotionFallback": "static"
34
+ },
35
+ "template": "{{css target}} { display: flex; gap: {{css params.gap}}; overflow: hidden; }\n{{css target}} > * { flex-shrink: 0; animation: motion-marquee-{{css id}} {{css params.duration}}s linear infinite {{css params.direction}}; }\n@keyframes motion-marquee-{{css id}} { from { transform: translateX(0) } to { transform: translateX(-100%) } }",
36
+ "demo": {
37
+ "html": "<div class=\"marquee-track\"><div class=\"d-marquee\"><div><span style=\"padding:0 2rem\">ALPHA</span><span style=\"padding:0 2rem\">BETA</span><span style=\"padding:0 2rem\">GAMMA</span><span style=\"padding:0 2rem\">DELTA</span></div><div><span style=\"padding:0 2rem\">ALPHA</span><span style=\"padding:0 2rem\">BETA</span><span style=\"padding:0 2rem\">GAMMA</span><span style=\"padding:0 2rem\">DELTA</span></div></div></div>",
38
+ "params": {
39
+ "duration": 24,
40
+ "gap": "3rem",
41
+ "direction": "normal"
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "parallaxLayer",
3
+ "version": "1.1.0",
4
+ "purpose": "Tiefenversatz: Ebene bewegt sich beim Scrollen langsamer oder schneller als der Rest.",
5
+ "output": "js",
6
+ "engine": "gsap.ScrollTrigger",
7
+ "paramSchema": {
8
+ "yPercent": {
9
+ "type": "number",
10
+ "default": -20,
11
+ "min": -100,
12
+ "max": 100
13
+ },
14
+ "scrub": {
15
+ "type": "number",
16
+ "default": 1,
17
+ "min": 0,
18
+ "max": 4
19
+ }
20
+ },
21
+ "triggerDefaults": {
22
+ "start": "top bottom",
23
+ "end": "bottom top"
24
+ },
25
+ "performance": {
26
+ "verified": true,
27
+ "lcpSafe": true,
28
+ "cost": 2,
29
+ "verifiedAt": "2026-06-12"
30
+ },
31
+ "a11y": {
32
+ "reducedMotionFallback": "instant-visible"
33
+ },
34
+ "template": "gsap.to({{target}}, { yPercent: {{params.yPercent}}, ease: 'none', scrollTrigger: { trigger: {{target}}, start: {{trigger.start}}, end: {{trigger.end}}, scrub: {{params.scrub}} } });"
35
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "pinnedSection",
3
+ "version": "1.1.0",
4
+ "purpose": "Sektion fixieren, waehrend der Inhalt hindurchscrollt.",
5
+ "output": "js",
6
+ "engine": "gsap.ScrollTrigger",
7
+ "paramSchema": {
8
+ "distance": {
9
+ "type": "string",
10
+ "default": "+=100%"
11
+ },
12
+ "pinSpacing": {
13
+ "type": "boolean",
14
+ "default": true
15
+ }
16
+ },
17
+ "triggerDefaults": {
18
+ "start": "top top"
19
+ },
20
+ "performance": {
21
+ "verified": true,
22
+ "lcpSafe": true,
23
+ "cost": 2,
24
+ "verifiedAt": "2026-06-12"
25
+ },
26
+ "a11y": {
27
+ "reducedMotionFallback": "instant-visible"
28
+ },
29
+ "template": "ScrollTrigger.create({ trigger: {{target}}, start: {{trigger.start}}, end: {{params.distance}}, pin: true, pinSpacing: {{params.pinSpacing}} });"
30
+ }
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "scaleOnScroll",
3
+ "version": "1.0.0",
4
+ "purpose": "Element skaliert sich proportional zum Scroll-Fortschritt (GSAP ScrollTrigger scrub).",
5
+ "output": "js",
6
+ "engine": "gsap.ScrollTrigger",
7
+ "paramSchema": {
8
+ "fromScale": {
9
+ "type": "number",
10
+ "default": 0.8,
11
+ "min": 0.2,
12
+ "max": 1
13
+ },
14
+ "toScale": {
15
+ "type": "number",
16
+ "default": 1,
17
+ "min": 0.5,
18
+ "max": 2
19
+ },
20
+ "transformOrigin": {
21
+ "type": "string",
22
+ "default": "center center",
23
+ "pattern": "^[a-zA-Z0-9% ]{2,40}$"
24
+ },
25
+ "scrub": {
26
+ "type": "number",
27
+ "default": 1,
28
+ "min": 0,
29
+ "max": 4
30
+ }
31
+ },
32
+ "triggerDefaults": {
33
+ "start": "top bottom",
34
+ "end": "center center"
35
+ },
36
+ "performance": {
37
+ "verified": true,
38
+ "lcpSafe": true,
39
+ "cost": 2,
40
+ "verifiedAt": "2026-06-12"
41
+ },
42
+ "a11y": {
43
+ "reducedMotionFallback": "instant-visible"
44
+ },
45
+ "template": "gsap.fromTo({{target}}, { scale: {{params.fromScale}}, transformOrigin: {{params.transformOrigin}} }, { scale: {{params.toScale}}, transformOrigin: {{params.transformOrigin}}, ease: \"none\", scrollTrigger: { trigger: {{target}}, start: {{trigger.start}}, end: {{trigger.end}}, scrub: {{params.scrub}} } });",
46
+ "demo": {
47
+ "html": "<div class=\"d-scaleOnScroll\" style=\"width:200px;height:200px;background:#6366f1;border-radius:12px;margin:60vh auto;\">Scale me</div>",
48
+ "params": {
49
+ "fromScale": 0.6,
50
+ "toScale": 1,
51
+ "transformOrigin": "center center",
52
+ "scrub": 1.5
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "scrollReveal",
3
+ "version": "1.1.0",
4
+ "purpose": "Element blendet beim Eintritt in den Viewport ein.",
5
+ "output": "js",
6
+ "engine": "gsap.ScrollTrigger",
7
+ "paramSchema": {
8
+ "from": {
9
+ "type": "transform",
10
+ "required": true
11
+ },
12
+ "duration": {
13
+ "type": "number",
14
+ "default": 0.8,
15
+ "min": 0.1,
16
+ "max": 3
17
+ },
18
+ "ease": {
19
+ "type": "string",
20
+ "default": "power3.out"
21
+ },
22
+ "stagger": {
23
+ "type": "number",
24
+ "default": 0,
25
+ "min": 0,
26
+ "max": 0.5
27
+ }
28
+ },
29
+ "triggerDefaults": {
30
+ "start": "top 80%",
31
+ "once": true
32
+ },
33
+ "performance": {
34
+ "verified": true,
35
+ "lcpSafe": true,
36
+ "cost": 1,
37
+ "verifiedAt": "2026-06-12"
38
+ },
39
+ "a11y": {
40
+ "reducedMotionFallback": "instant-visible"
41
+ },
42
+ "template": "gsap.from({{target}}, Object.assign({{params.from}}, { duration: {{params.duration}}, ease: {{params.ease}}, stagger: {{params.stagger}}, scrollTrigger: { trigger: {{target}}, start: {{trigger.start}}, once: {{trigger.once}} } }));"
43
+ }
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "staggerReveal",
3
+ "version": "1.1.0",
4
+ "purpose": "Mehrere Kind-Elemente gestaffelt nacheinander einblenden.",
5
+ "output": "js",
6
+ "engine": "gsap.ScrollTrigger",
7
+ "paramSchema": {
8
+ "from": {
9
+ "type": "transform",
10
+ "required": true
11
+ },
12
+ "duration": {
13
+ "type": "number",
14
+ "default": 0.6,
15
+ "min": 0.1,
16
+ "max": 3
17
+ },
18
+ "ease": {
19
+ "type": "string",
20
+ "default": "power2.out"
21
+ },
22
+ "stagger": {
23
+ "type": "number",
24
+ "default": 0.12,
25
+ "min": 0.02,
26
+ "max": 0.6
27
+ }
28
+ },
29
+ "triggerDefaults": {
30
+ "start": "top 85%",
31
+ "once": true
32
+ },
33
+ "performance": {
34
+ "verified": true,
35
+ "lcpSafe": true,
36
+ "cost": 1,
37
+ "verifiedAt": "2026-06-12"
38
+ },
39
+ "a11y": {
40
+ "reducedMotionFallback": "instant-visible"
41
+ },
42
+ "template": "gsap.from({{target}}, Object.assign({{params.from}}, { duration: {{params.duration}}, ease: {{params.ease}}, stagger: {{params.stagger}}, scrollTrigger: { trigger: {{target}}, start: {{trigger.start}}, once: {{trigger.once}} } }));"
43
+ }
@@ -0,0 +1,56 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://motionspec.dev/schema/1.0/motionspec.schema.json",
4
+ "title": "MotionSpec",
5
+ "description": "Formale Zwischensprache fuer Web-Motion. FROZEN v1 (ADR-0001, 2026-06-15). Field shape is the published, stable contract. specVersion \"1.0\" is stable; \"0.1\" deprecated, accepted until v1.2. Static contract; the dynamic check (Primitive-Allow-List, per-primitive params, deprecation note) is the Trust-Boundary-Validator in compiler/validate.js. responsive is a known-but-deferred key (rejected at runtime until 0.2).",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["specVersion", "meta", "motions"],
9
+ "properties": {
10
+ "specVersion": { "type": "string", "enum": ["1.0", "0.1"] },
11
+ "catalogVersion": { "type": "string", "pattern": "^[0-9a-f]{16}$", "description": "Optional reproducibility pin (ADR-0001 D2): the 16-char catalog hash this spec was authored against. Rejected at runtime if it does not match the loaded catalog." },
12
+ "meta": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "required": ["target"],
16
+ "properties": {
17
+ "project": { "type": "string" },
18
+ "target": { "type": "string", "enum": ["vanilla-gsap"] },
19
+ "createdWith": { "type": "string" }
20
+ }
21
+ },
22
+ "globals": {
23
+ "type": "object",
24
+ "additionalProperties": false,
25
+ "properties": {
26
+ "respectReducedMotion": { "type": "boolean" },
27
+ "defaultEase": { "type": "string" }
28
+ }
29
+ },
30
+ "motions": {
31
+ "type": "array",
32
+ "minItems": 1,
33
+ "items": {
34
+ "type": "object",
35
+ "additionalProperties": false,
36
+ "required": ["id", "primitive", "target"],
37
+ "properties": {
38
+ "id": { "type": "string", "minLength": 1 },
39
+ "primitive": { "type": "string", "description": "Muss ein Name aus dem Primitive-Katalog sein. Allow-List, zur Laufzeit geprueft." },
40
+ "target": { "type": "string", "minLength": 1 },
41
+ "params": { "type": "object" },
42
+ "trigger": {
43
+ "type": "object",
44
+ "additionalProperties": false,
45
+ "properties": {
46
+ "start": { "type": "string" },
47
+ "end": { "type": "string" },
48
+ "once": { "type": "boolean" }
49
+ }
50
+ },
51
+ "responsive": { "type": "object" }
52
+ }
53
+ }
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ /*
3
+ * Catalog SemVer diff-gate — makes ADR-0001 D2 machine-checkable.
4
+ * ----------------------------------------------------------------------
5
+ * D2 says catalog changes follow SemVer:
6
+ * patch = template/purpose fix (no contract change)
7
+ * minor = new OPTIONAL param, or a new primitive
8
+ * major = removed/renamed primitive, removed param, type change,
9
+ * a param made required, or TIGHTENED bounds (min raised / max
10
+ * lowered / pattern added or changed)
11
+ *
12
+ * The meta-schema only checks the version FORMAT. This module checks the
13
+ * version DIRECTION: given the released baseline (catalog.lock.json) and the
14
+ * current catalog, it classifies each primitive's change and asserts the
15
+ * version bump is at least the required class. A tightened bound shipped as a
16
+ * "patch" is a violation the test will catch.
17
+ *
18
+ * Baseline semantics: the lockfile is the LAST RELEASED state. You bump a
19
+ * primitive's version in its .json when you change it; the lock stays put
20
+ * until you deliberately relock at release time (`npm run catalog-lock`).
21
+ */
22
+
23
+ function snapshotPrimitive(p) {
24
+ const params = {};
25
+ const ps = p.paramSchema || {};
26
+ Object.keys(ps).sort().forEach((k) => {
27
+ const d = ps[k] || {};
28
+ params[k] = {
29
+ type: d.type,
30
+ required: !!d.required,
31
+ min: typeof d.min === "number" ? d.min : null,
32
+ max: typeof d.max === "number" ? d.max : null,
33
+ pattern: typeof d.pattern === "string" ? d.pattern : null,
34
+ };
35
+ });
36
+ return { version: p.version, output: p.output, params };
37
+ }
38
+
39
+ function snapshotCatalog(catalog) {
40
+ const out = {};
41
+ Object.keys(catalog).sort().forEach((name) => { out[name] = snapshotPrimitive(catalog[name]); });
42
+ return out;
43
+ }
44
+
45
+ function parseSemver(v) {
46
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v));
47
+ if (!m) return null;
48
+ return { major: +m[1], minor: +m[2], patch: +m[3] };
49
+ }
50
+
51
+ function cmpSemver(a, b) {
52
+ const x = parseSemver(a), y = parseSemver(b);
53
+ if (!x || !y) return null;
54
+ if (x.major !== y.major) return x.major - y.major;
55
+ if (x.minor !== y.minor) return x.minor - y.minor;
56
+ return x.patch - y.patch;
57
+ }
58
+
59
+ /* Classify the contract change of ONE primitive between baseline and current.
60
+ * Returns { class: "major"|"minor"|"patch"|"none", reasons: [] }. */
61
+ function classifyChange(prev, curr) {
62
+ const reasons = [];
63
+ let cls = "none";
64
+ const bump = (level, why) => {
65
+ reasons.push(why);
66
+ const rank = { none: 0, patch: 1, minor: 2, major: 3 };
67
+ if (rank[level] > rank[cls]) cls = level;
68
+ };
69
+
70
+ if (prev.output !== curr.output) bump("major", "output changed " + prev.output + "->" + curr.output);
71
+
72
+ const prevKeys = Object.keys(prev.params);
73
+ const currKeys = Object.keys(curr.params);
74
+
75
+ prevKeys.forEach((k) => {
76
+ if (!(k in curr.params)) { bump("major", "param removed: " + k); return; }
77
+ const a = prev.params[k], b = curr.params[k];
78
+ if (a.type !== b.type) bump("major", "param type changed: " + k + " " + a.type + "->" + b.type);
79
+ if (!a.required && b.required) bump("major", "param made required: " + k);
80
+ if (a.min !== null && (b.min === null || b.min > a.min)) bump("major", "min raised: " + k + " " + a.min + "->" + b.min);
81
+ if (a.max !== null && (b.max === null || b.max < a.max)) bump("major", "max lowered: " + k + " " + a.max + "->" + b.max);
82
+ if (a.pattern === null && b.pattern !== null) bump("major", "pattern added: " + k);
83
+ else if (a.pattern !== null && b.pattern !== a.pattern) bump("major", "pattern changed: " + k);
84
+ });
85
+
86
+ currKeys.forEach((k) => {
87
+ if (!(k in prev.params)) {
88
+ if (curr.params[k].required) bump("major", "new REQUIRED param: " + k);
89
+ else bump("minor", "new optional param: " + k);
90
+ }
91
+ });
92
+
93
+ // Any non-structural diff (template/purpose) is detected by the caller via
94
+ // the catalogVersion hash; here, if nothing above fired, it's at most patch.
95
+ return { class: cls, reasons };
96
+ }
97
+
98
+ /* Diff baseline lock vs current catalog snapshot.
99
+ * Returns { violations: [{name, msg}], added: [], removed: [] }. */
100
+ function diffCatalog(lock, current) {
101
+ const violations = [];
102
+ const added = [];
103
+ const removed = [];
104
+
105
+ Object.keys(lock).forEach((name) => {
106
+ if (!(name in current)) {
107
+ removed.push(name);
108
+ violations.push({ name, msg: 'primitive "' + name + '" REMOVED — breaking (major) catalog change. Acknowledge: update catalog.lock.json and note it as breaking.' });
109
+ }
110
+ });
111
+
112
+ Object.keys(current).forEach((name) => {
113
+ if (!(name in lock)) { added.push(name); return; } // new primitive = minor, allowed
114
+ const prev = lock[name], curr = current[name];
115
+ const { class: cls, reasons } = classifyChange(prev, curr);
116
+ const c = cmpSemver(curr.version, prev.version);
117
+
118
+ if (cls === "major" && !(parseSemver(curr.version).major > parseSemver(prev.version).major)) {
119
+ violations.push({ name, msg: '"' + name + '" needs a MAJOR bump (was ' + prev.version + ", now " + curr.version + "). Reason(s): " + reasons.join("; ") });
120
+ } else if (cls === "minor") {
121
+ const pv = parseSemver(prev.version), cv = parseSemver(curr.version);
122
+ const okMinor = cv.major > pv.major || (cv.major === pv.major && cv.minor > pv.minor);
123
+ if (!okMinor) violations.push({ name, msg: '"' + name + '" needs at least a MINOR bump (was ' + prev.version + ", now " + curr.version + "). Reason(s): " + reasons.join("; ") });
124
+ } else if (cls === "patch") {
125
+ if (c !== null && c <= 0) violations.push({ name, msg: '"' + name + '" changed but version not bumped (still ' + curr.version + ")." });
126
+ }
127
+ // class "none": no contract change; version may stay or move, no constraint.
128
+ });
129
+
130
+ return { violations, added, removed };
131
+ }
132
+
133
+ module.exports = { snapshotPrimitive, snapshotCatalog, classifyChange, diffCatalog, parseSemver, cmpSemver };
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ /*
3
+ * Primitive-Katalog — Laden + Meta-Schema + Versionierung
4
+ *
5
+ * Audit-Befund #8 (2026-06-12): Primitive-Dateien sind selbst eine
6
+ * Vertrauensgrenze. Jede Datei wird beim Laden gegen ein Meta-Schema
7
+ * geprueft (fail-closed): Groessen-Limits, Pflichtfelder, erlaubte
8
+ * paramSchema-Typen und ReDoS-Screening fuer pattern-Felder.
9
+ *
10
+ * catalogVersion = Hash ueber alle Primitiv-Definitionen -> Cache-Schluessel
11
+ * invalidiert automatisch bei jeder Katalog-Aenderung (auch Patches).
12
+ */
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+ const crypto = require("crypto");
16
+
17
+
18
+ const MAX_FILE_BYTES = 64 * 1024;
19
+ const MAX_TEMPLATE_CHARS = 4096;
20
+ const MAX_PATTERN_CHARS = 100;
21
+ const NAME_RE = /^[A-Za-z][A-Za-z0-9]{1,40}$/;
22
+ const PARAM_TYPES = ["number", "string", "boolean", "transform"];
23
+ const OUTPUTS = ["js", "css"];
24
+ /* ReDoS-Heuristik: verschachtelte Quantifizierer wie (a+)+ oder (a*)* */
25
+ const NESTED_QUANTIFIER_RE = /\([^)]*[+*][^)]*\)\s*[+*{]/;
26
+
27
+ function screenPattern(pat, where, fail) {
28
+ if (typeof pat !== "string") fail(where + ": pattern muss ein String sein.");
29
+ if (pat.length > MAX_PATTERN_CHARS) fail(where + ": pattern zu lang (>" + MAX_PATTERN_CHARS + ").");
30
+ if (NESTED_QUANTIFIER_RE.test(pat)) fail(where + ": pattern enthaelt verschachtelte Quantifizierer (ReDoS-Risiko).");
31
+ try { new RegExp(pat); } catch (e) { fail(where + ": pattern ist kein gueltiger regulaerer Ausdruck (" + e.message + ")."); }
32
+ }
33
+
34
+ function checkPrimitive(p, file) {
35
+ const fail = (msg) => { throw new Error("Katalog abgelehnt (" + file + "): " + msg); };
36
+ if (typeof p !== "object" || p === null || Array.isArray(p)) fail("kein Objekt");
37
+ if (!NAME_RE.test(p.name || "")) fail("name fehlt oder verletzt " + NAME_RE);
38
+ if (typeof p.version !== "string" || !/^\d+\.\d+\.\d+$/.test(p.version)) fail("version fehlt oder ist kein SemVer.");
39
+ if (OUTPUTS.indexOf(p.output) === -1) fail('output muss "js" oder "css" sein.');
40
+ if (typeof p.template !== "string" || !p.template) fail("template fehlt.");
41
+ if (p.template.length > MAX_TEMPLATE_CHARS) fail("template zu lang (>" + MAX_TEMPLATE_CHARS + ").");
42
+ const ps = p.paramSchema || {};
43
+ if (typeof ps !== "object" || ps === null) fail("paramSchema muss ein Objekt sein.");
44
+ Object.keys(ps).forEach((k) => {
45
+ const def = ps[k];
46
+ if (typeof def !== "object" || def === null) fail('paramSchema.' + k + " muss ein Objekt sein.");
47
+ if (PARAM_TYPES.indexOf(def.type) === -1) fail('paramSchema.' + k + ".type unzulaessig (erlaubt: " + PARAM_TYPES.join(", ") + ").");
48
+ if (def.pattern !== undefined) screenPattern(def.pattern, "paramSchema." + k, fail);
49
+ });
50
+ return p;
51
+ }
52
+
53
+ /* Phase C / C3: reiner Katalog-Bau aus bereits geparsten Primitiv-Objekten
54
+ * (kein fs) — damit der Cloudflare Worker den gebundelten Katalog mit exakt
55
+ * derselben Validierung + demselben Pin erzeugt wie loadCatalog(). Reihenfolge
56
+ * egal: catalogVersion() sortiert nach Namen. */
57
+ function buildCatalog(primitives) {
58
+ const cat = {};
59
+ primitives.forEach((p, i) => {
60
+ checkPrimitive(p, (p && p.name) ? p.name : ("#" + i));
61
+ if (cat[p.name]) throw new Error("Doppeltes Primitiv: " + p.name);
62
+ cat[p.name] = p;
63
+ });
64
+ return cat;
65
+ }
66
+
67
+ function loadCatalog(dir) {
68
+ const d = dir || path.join(__dirname, "..", "..", "primitives");
69
+ const cat = {};
70
+ fs.readdirSync(d)
71
+ .filter((f) => f.endsWith(".json"))
72
+ .sort()
73
+ .forEach((f) => {
74
+ const full = path.join(d, f);
75
+ const stat = fs.statSync(full);
76
+ if (stat.size > MAX_FILE_BYTES) throw new Error("Katalog abgelehnt (" + f + "): Datei zu gross (>" + MAX_FILE_BYTES + " Bytes).");
77
+ let p;
78
+ try { p = JSON.parse(fs.readFileSync(full, "utf8")); }
79
+ catch (e) { throw new Error("Katalog abgelehnt (" + f + "): kein gueltiges JSON (" + e.message + ").", { cause: e }); }
80
+ checkPrimitive(p, f);
81
+ if (cat[p.name]) throw new Error("Doppeltes Primitiv: " + p.name);
82
+ cat[p.name] = p;
83
+ });
84
+ return cat;
85
+ }
86
+
87
+ function catalogVersion(catalog) {
88
+ const stable = JSON.stringify(
89
+ Object.keys(catalog).sort().map((k) => catalog[k])
90
+ );
91
+ return crypto.createHash("sha256").update(stable).digest("hex").slice(0, 16);
92
+ }
93
+
94
+ module.exports = { loadCatalog, buildCatalog, catalogVersion, checkPrimitive };