@shaykec/bridge 0.1.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,256 @@
1
+ /**
2
+ * Tests for ClaudeTeach bridge template engine.
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach } from 'vitest';
6
+ import { fillTemplate, loadTemplate, renderTemplate, listTemplates, clearTemplateCache } from './templates.js';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // fillTemplate
10
+ // ---------------------------------------------------------------------------
11
+
12
+ describe('fillTemplate', () => {
13
+ // --- Simple placeholders ---
14
+
15
+ it('replaces a simple placeholder', () => {
16
+ expect(fillTemplate('Hello {{name}}!', { name: 'Alice' })).toBe('Hello Alice!');
17
+ });
18
+
19
+ it('replaces multiple different placeholders', () => {
20
+ const tpl = '{{greeting}}, {{name}}!';
21
+ expect(fillTemplate(tpl, { greeting: 'Hi', name: 'Bob' })).toBe('Hi, Bob!');
22
+ });
23
+
24
+ it('replaces the same placeholder used more than once', () => {
25
+ const tpl = '{{x}} and {{x}}';
26
+ expect(fillTemplate(tpl, { x: 'yes' })).toBe('yes and yes');
27
+ });
28
+
29
+ // --- Nested placeholders ---
30
+
31
+ it('resolves nested dot-notation keys', () => {
32
+ const tpl = '{{user.name}} has {{user.xp}} XP';
33
+ expect(fillTemplate(tpl, { user: { name: 'Alice', xp: 100 } })).toBe('Alice has 100 XP');
34
+ });
35
+
36
+ it('resolves deeply nested keys', () => {
37
+ const tpl = '{{a.b.c}}';
38
+ expect(fillTemplate(tpl, { a: { b: { c: 'deep' } } })).toBe('deep');
39
+ });
40
+
41
+ // --- Missing keys ---
42
+
43
+ it('replaces missing keys with empty string', () => {
44
+ expect(fillTemplate('Hello {{missing}}!', {})).toBe('Hello !');
45
+ });
46
+
47
+ it('replaces missing nested keys with empty string', () => {
48
+ expect(fillTemplate('{{a.b.c}}', { a: {} })).toBe('');
49
+ });
50
+
51
+ // --- HTML escaping ---
52
+
53
+ it('escapes HTML special characters in values', () => {
54
+ expect(fillTemplate('{{val}}', { val: '<script>alert("xss")</script>' }))
55
+ .toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
56
+ });
57
+
58
+ it('escapes ampersands', () => {
59
+ expect(fillTemplate('{{val}}', { val: 'A & B' })).toBe('A &amp; B');
60
+ });
61
+
62
+ it('escapes single quotes', () => {
63
+ expect(fillTemplate('{{val}}', { val: "it's" })).toBe('it&#39;s');
64
+ });
65
+
66
+ // --- Array blocks ---
67
+
68
+ it('renders an array block with primitives using {{.}}', () => {
69
+ const tpl = '{{#items}}{{.}},{{/items}}';
70
+ expect(fillTemplate(tpl, { items: ['a', 'b', 'c'] })).toBe('a,b,c,');
71
+ });
72
+
73
+ it('renders an array block with objects using {{.property}}', () => {
74
+ const tpl = '{{#users}}{{.name}};{{/users}}';
75
+ expect(fillTemplate(tpl, { users: [{ name: 'Alice' }, { name: 'Bob' }] })).toBe('Alice;Bob;');
76
+ });
77
+
78
+ it('renders {{@index}} in array blocks', () => {
79
+ const tpl = '{{#items}}{{@index}}:{{.}},{{/items}}';
80
+ expect(fillTemplate(tpl, { items: ['x', 'y'] })).toBe('0:x,1:y,');
81
+ });
82
+
83
+ it('renders empty string for non-array values in block syntax', () => {
84
+ const tpl = '{{#items}}{{.}}{{/items}}';
85
+ expect(fillTemplate(tpl, { items: 'not an array' })).toBe('');
86
+ });
87
+
88
+ it('renders empty string for missing array key', () => {
89
+ const tpl = '{{#items}}{{.}}{{/items}}';
90
+ expect(fillTemplate(tpl, {})).toBe('');
91
+ });
92
+
93
+ it('handles empty arrays', () => {
94
+ const tpl = '{{#items}}{{.}}{{/items}}';
95
+ expect(fillTemplate(tpl, { items: [] })).toBe('');
96
+ });
97
+
98
+ it('renders {{.}} for objects as escaped JSON', () => {
99
+ const tpl = '{{#items}}{{.}},{{/items}}';
100
+ const result = fillTemplate(tpl, { items: [{ a: 1 }] });
101
+ // {{.}} on an object gives escaped JSON
102
+ expect(result).toContain('&quot;a&quot;');
103
+ });
104
+
105
+ it('escapes HTML in array item properties', () => {
106
+ const tpl = '{{#items}}{{.name}}{{/items}}';
107
+ expect(fillTemplate(tpl, { items: [{ name: '<b>bold</b>' }] }))
108
+ .toBe('&lt;b&gt;bold&lt;/b&gt;');
109
+ });
110
+
111
+ // --- JSON injection ---
112
+
113
+ it('renders JSON injection with triple braces', () => {
114
+ const tpl = 'var d = {{{json:data}}};';
115
+ expect(fillTemplate(tpl, { data: { a: 1, b: 'hello' } }))
116
+ .toBe('var d = {"a":1,"b":"hello"};');
117
+ });
118
+
119
+ it('renders null for missing JSON key', () => {
120
+ const tpl = '{{{json:missing}}}';
121
+ expect(fillTemplate(tpl, {})).toBe('null');
122
+ });
123
+
124
+ it('renders nested JSON injection', () => {
125
+ const tpl = '{{{json:user.profile}}}';
126
+ expect(fillTemplate(tpl, { user: { profile: { age: 30 } } }))
127
+ .toBe('{"age":30}');
128
+ });
129
+
130
+ it('renders arrays via JSON injection', () => {
131
+ const tpl = '{{{json:list}}}';
132
+ expect(fillTemplate(tpl, { list: [1, 2, 3] })).toBe('[1,2,3]');
133
+ });
134
+
135
+ // --- Edge cases ---
136
+
137
+ it('handles empty data object', () => {
138
+ const tpl = '{{name}} — {{age}}';
139
+ expect(fillTemplate(tpl, {})).toBe(' — ');
140
+ });
141
+
142
+ it('handles template with no placeholders', () => {
143
+ expect(fillTemplate('plain text', { name: 'Alice' })).toBe('plain text');
144
+ });
145
+
146
+ it('converts numbers to strings', () => {
147
+ expect(fillTemplate('{{val}}', { val: 42 })).toBe('42');
148
+ });
149
+
150
+ it('converts booleans to strings', () => {
151
+ expect(fillTemplate('{{val}}', { val: true })).toBe('true');
152
+ });
153
+
154
+ it('handles null value as empty string', () => {
155
+ expect(fillTemplate('{{val}}', { val: null })).toBe('');
156
+ });
157
+
158
+ it('handles undefined value as empty string', () => {
159
+ expect(fillTemplate('{{val}}', { val: undefined })).toBe('');
160
+ });
161
+ });
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // loadTemplate
165
+ // ---------------------------------------------------------------------------
166
+
167
+ describe('loadTemplate', () => {
168
+ beforeEach(() => {
169
+ clearTemplateCache();
170
+ });
171
+
172
+ it('loads an existing template by name (without extension)', () => {
173
+ const content = loadTemplate('celebrate');
174
+ expect(typeof content).toBe('string');
175
+ expect(content.length).toBeGreaterThan(0);
176
+ });
177
+
178
+ it('loads an existing template by name (with .html extension)', () => {
179
+ const content = loadTemplate('celebrate.html');
180
+ expect(typeof content).toBe('string');
181
+ expect(content.length).toBeGreaterThan(0);
182
+ });
183
+
184
+ it('returns null for a non-existent template', () => {
185
+ expect(loadTemplate('nonexistent-template-xyz')).toBeNull();
186
+ });
187
+
188
+ it('caches templates (second call returns same content)', () => {
189
+ const first = loadTemplate('celebrate');
190
+ const second = loadTemplate('celebrate');
191
+ expect(first).toBe(second);
192
+ });
193
+ });
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // renderTemplate
197
+ // ---------------------------------------------------------------------------
198
+
199
+ describe('renderTemplate', () => {
200
+ beforeEach(() => {
201
+ clearTemplateCache();
202
+ });
203
+
204
+ it('loads and fills a template with data', () => {
205
+ const result = renderTemplate('celebrate', { title: 'Well done!' });
206
+ expect(typeof result).toBe('string');
207
+ expect(result.length).toBeGreaterThan(0);
208
+ });
209
+
210
+ it('returns null for a non-existent template', () => {
211
+ expect(renderTemplate('nonexistent-template-xyz', {})).toBeNull();
212
+ });
213
+ });
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // listTemplates
217
+ // ---------------------------------------------------------------------------
218
+
219
+ describe('listTemplates', () => {
220
+ it('returns an array of template names', () => {
221
+ const templates = listTemplates();
222
+ expect(Array.isArray(templates)).toBe(true);
223
+ expect(templates.length).toBeGreaterThan(0);
224
+ });
225
+
226
+ it('includes known templates', () => {
227
+ const templates = listTemplates();
228
+ expect(templates).toContain('celebrate');
229
+ expect(templates).toContain('dashboard');
230
+ });
231
+
232
+ it('returns names without .html extension', () => {
233
+ const templates = listTemplates();
234
+ for (const name of templates) {
235
+ expect(name.endsWith('.html')).toBe(false);
236
+ }
237
+ });
238
+ });
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // clearTemplateCache
242
+ // ---------------------------------------------------------------------------
243
+
244
+ describe('clearTemplateCache', () => {
245
+ it('does not throw', () => {
246
+ expect(() => clearTemplateCache()).not.toThrow();
247
+ });
248
+
249
+ it('causes next loadTemplate call to re-read from disk', () => {
250
+ const first = loadTemplate('celebrate');
251
+ clearTemplateCache();
252
+ const second = loadTemplate('celebrate');
253
+ // Both should have the same content (file hasn't changed)
254
+ expect(first).toEqual(second);
255
+ });
256
+ });
@@ -0,0 +1,259 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>ClaudeTeach — Celebration!</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
+ background: #0d1117;
12
+ color: #c9d1d9;
13
+ display: flex;
14
+ flex-direction: column;
15
+ align-items: center;
16
+ justify-content: center;
17
+ min-height: 100vh;
18
+ overflow: hidden;
19
+ position: relative;
20
+ }
21
+ canvas {
22
+ position: fixed;
23
+ top: 0;
24
+ left: 0;
25
+ width: 100%;
26
+ height: 100%;
27
+ pointer-events: none;
28
+ z-index: 10;
29
+ }
30
+ .celebration {
31
+ text-align: center;
32
+ z-index: 5;
33
+ animation: fadeInUp 0.6s ease;
34
+ }
35
+ @keyframes fadeInUp {
36
+ from { opacity: 0; transform: translateY(30px); }
37
+ to { opacity: 1; transform: translateY(0); }
38
+ }
39
+ .celebration-icon {
40
+ font-size: 4rem;
41
+ margin-bottom: 1rem;
42
+ animation: bounce 0.6s ease infinite alternate;
43
+ }
44
+ @keyframes bounce {
45
+ from { transform: translateY(0); }
46
+ to { transform: translateY(-10px); }
47
+ }
48
+ .celebration h1 {
49
+ font-size: 2rem;
50
+ color: #58a6ff;
51
+ margin-bottom: 0.5rem;
52
+ }
53
+ .celebration .subtitle {
54
+ font-size: 1.2rem;
55
+ color: #8b949e;
56
+ margin-bottom: 1.5rem;
57
+ }
58
+ .xp-badge {
59
+ display: inline-block;
60
+ background: linear-gradient(135deg, #238636, #3fb950);
61
+ color: #fff;
62
+ padding: 0.6rem 1.5rem;
63
+ border-radius: 20px;
64
+ font-size: 1.3rem;
65
+ font-weight: 700;
66
+ animation: pulse 1s ease infinite;
67
+ }
68
+ @keyframes pulse {
69
+ 0%, 100% { transform: scale(1); }
70
+ 50% { transform: scale(1.05); }
71
+ }
72
+ .belt-change {
73
+ margin-top: 1.5rem;
74
+ padding: 1rem 2rem;
75
+ background: #161b22;
76
+ border: 2px solid #58a6ff;
77
+ border-radius: 12px;
78
+ display: none;
79
+ }
80
+ .belt-change.visible { display: block; }
81
+ .belt-change .new-belt {
82
+ font-size: 2.5rem;
83
+ margin-bottom: 0.5rem;
84
+ }
85
+ .belt-change .belt-text {
86
+ color: #58a6ff;
87
+ font-size: 1.1rem;
88
+ font-weight: 600;
89
+ }
90
+ </style>
91
+ </head>
92
+ <body>
93
+ <canvas id="confetti"></canvas>
94
+ <div class="celebration">
95
+ <div class="celebration-icon" id="icon"></div>
96
+ <h1 id="title">Great Job!</h1>
97
+ <div class="subtitle" id="subtitle">You completed the challenge</div>
98
+ <div class="xp-badge" id="xpBadge">+20 XP</div>
99
+ <div class="belt-change" id="beltChange">
100
+ <div class="new-belt" id="newBeltIcon"></div>
101
+ <div class="belt-text" id="beltText">New Belt Unlocked!</div>
102
+ </div>
103
+ </div>
104
+
105
+ <script>
106
+ /* --- Bridge connection --- */
107
+ var wsBridge = null;
108
+ (function connectBridge() {
109
+ try {
110
+ wsBridge = new WebSocket('ws://' + location.host + '/ws');
111
+ wsBridge.onopen = function() {
112
+ wsBridge.send(JSON.stringify({
113
+ v: 1, type: 'sys:connect',
114
+ payload: { clientType: 'template' },
115
+ source: 'template', timestamp: Date.now()
116
+ }));
117
+ };
118
+ wsBridge.onmessage = function(event) {
119
+ try {
120
+ var msg = JSON.parse(event.data);
121
+ if (msg.type === 'canvas:celebrate' && msg.payload) {
122
+ init(msg.payload);
123
+ }
124
+ } catch(e) {}
125
+ };
126
+ wsBridge.onerror = function() { wsBridge = null; };
127
+ wsBridge.onclose = function() { wsBridge = null; };
128
+ } catch(e) { wsBridge = null; }
129
+ })();
130
+
131
+ const CELEBRATION_CONFIG = {
132
+ 'xp': { icon: '\u2B50', title: 'XP Earned!', confettiCount: 50 },
133
+ 'level-up': { icon: '\uD83C\uDF89', title: 'Level Up!', confettiCount: 150 },
134
+ 'perfect-score': { icon: '\uD83C\uDFC6', title: 'Perfect Score!', confettiCount: 200 },
135
+ };
136
+
137
+ let celebrateData = {
138
+ type: 'xp',
139
+ xpAwarded: 20,
140
+ newBelt: null,
141
+ };
142
+
143
+ function init(data) {
144
+ if (data) celebrateData = { ...celebrateData, ...data };
145
+
146
+ const config = CELEBRATION_CONFIG[celebrateData.type] || CELEBRATION_CONFIG['xp'];
147
+
148
+ document.getElementById('icon').textContent = config.icon;
149
+ document.getElementById('title').textContent = config.title;
150
+
151
+ if (celebrateData.xpAwarded) {
152
+ document.getElementById('xpBadge').textContent = `+${celebrateData.xpAwarded} XP`;
153
+ }
154
+
155
+ if (celebrateData.type === 'perfect-score') {
156
+ document.getElementById('subtitle').textContent = 'Flawless performance!';
157
+ } else if (celebrateData.type === 'level-up') {
158
+ document.getElementById('subtitle').textContent = 'You reached a new level!';
159
+ }
160
+
161
+ if (celebrateData.newBelt) {
162
+ const beltChange = document.getElementById('beltChange');
163
+ beltChange.classList.add('visible');
164
+ document.getElementById('newBeltIcon').textContent = celebrateData.newBelt.badge || '\u2B1C';
165
+ document.getElementById('beltText').textContent =
166
+ `${celebrateData.newBelt.name || 'New'} Belt Unlocked!`;
167
+ }
168
+
169
+ launchConfetti(config.confettiCount);
170
+ }
171
+
172
+ // --- Confetti animation ---
173
+ function launchConfetti(count) {
174
+ const canvas = document.getElementById('confetti');
175
+ const ctx = canvas.getContext('2d');
176
+
177
+ canvas.width = window.innerWidth;
178
+ canvas.height = window.innerHeight;
179
+
180
+ const colors = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#fff'];
181
+ const particles = [];
182
+
183
+ for (let i = 0; i < count; i++) {
184
+ particles.push({
185
+ x: Math.random() * canvas.width,
186
+ y: Math.random() * canvas.height - canvas.height,
187
+ w: Math.random() * 8 + 4,
188
+ h: Math.random() * 4 + 2,
189
+ color: colors[Math.floor(Math.random() * colors.length)],
190
+ vx: (Math.random() - 0.5) * 4,
191
+ vy: Math.random() * 3 + 2,
192
+ rotation: Math.random() * 360,
193
+ rotationSpeed: (Math.random() - 0.5) * 10,
194
+ opacity: 1,
195
+ });
196
+ }
197
+
198
+ let frame = 0;
199
+ const maxFrames = 300;
200
+
201
+ function animate() {
202
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
203
+ frame++;
204
+
205
+ let alive = false;
206
+
207
+ for (const p of particles) {
208
+ p.x += p.vx;
209
+ p.vy += 0.05; // gravity
210
+ p.y += p.vy;
211
+ p.rotation += p.rotationSpeed;
212
+
213
+ // Fade out in last quarter
214
+ if (frame > maxFrames * 0.75) {
215
+ p.opacity = Math.max(0, 1 - (frame - maxFrames * 0.75) / (maxFrames * 0.25));
216
+ }
217
+
218
+ if (p.y < canvas.height + 20 && p.opacity > 0) {
219
+ alive = true;
220
+ ctx.save();
221
+ ctx.translate(p.x, p.y);
222
+ ctx.rotate((p.rotation * Math.PI) / 180);
223
+ ctx.globalAlpha = p.opacity;
224
+ ctx.fillStyle = p.color;
225
+ ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
226
+ ctx.restore();
227
+ }
228
+ }
229
+
230
+ if (alive && frame < maxFrames) {
231
+ requestAnimationFrame(animate);
232
+ } else {
233
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
234
+ }
235
+ }
236
+
237
+ requestAnimationFrame(animate);
238
+ }
239
+
240
+ window.addEventListener('resize', () => {
241
+ const canvas = document.getElementById('confetti');
242
+ canvas.width = window.innerWidth;
243
+ canvas.height = window.innerHeight;
244
+ });
245
+
246
+ window.addEventListener('message', (e) => {
247
+ if (e.data && e.data.type === 'celebrate-data') {
248
+ init(e.data.payload);
249
+ }
250
+ });
251
+
252
+ if (window.__CELEBRATE_DATA__) {
253
+ init(window.__CELEBRATE_DATA__);
254
+ } else {
255
+ init();
256
+ }
257
+ </script>
258
+ </body>
259
+ </html>