@shaykec/claude-teach 0.6.1 → 0.6.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaykec/claude-teach",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Socratic AI teaching platform — learn anything through guided dialogue, visual canvas, and gamification",
5
5
  "type": "module",
6
6
  "main": "src/cli.js",
package/src/author.js CHANGED
@@ -272,7 +272,38 @@ export async function validatePack(packPath, builtinSlugs = []) {
272
272
  }
273
273
  }
274
274
 
275
- // 4. Validate prerequisites
275
+ // 4. Validate media entries
276
+ for (const dirName of actualDirs) {
277
+ const moduleYamlPath = join(modulesDir, dirName, 'module.yaml');
278
+ if (!existsSync(moduleYamlPath)) continue;
279
+ try {
280
+ const mod = yaml.load(readFileSync(moduleYamlPath, 'utf-8'));
281
+ if (Array.isArray(mod.media)) {
282
+ for (let i = 0; i < mod.media.length; i++) {
283
+ const entry = mod.media[i];
284
+ const prefix = `modules/${dirName}: media[${i}]`;
285
+ if (!entry.url) {
286
+ errors.push(`${prefix}: missing "url" field`);
287
+ } else if (!/^https?:\/\/.+/.test(entry.url)) {
288
+ warnings.push(`${prefix}: url should be a full URL (got "${entry.url}")`);
289
+ }
290
+ if (!entry.type) {
291
+ warnings.push(`${prefix}: missing "type" field (image, video, audio, link)`);
292
+ }
293
+ if (entry.type === 'image' && !entry.alt) {
294
+ warnings.push(`${prefix}: images should have "alt" text for accessibility`);
295
+ }
296
+ if ((entry.type === 'audio' || entry.type === 'video') && !entry.label && !entry.title) {
297
+ warnings.push(`${prefix}: audio/video should have "label" or "title"`);
298
+ }
299
+ }
300
+ }
301
+ } catch {
302
+ // Already reported parse error above
303
+ }
304
+ }
305
+
306
+ // 5. Validate prerequisites
276
307
  const validSlugs = new Set([...allPackSlugs, ...builtinSlugs]);
277
308
  for (const dirName of actualDirs) {
278
309
  const moduleYamlPath = join(modulesDir, dirName, 'module.yaml');
@@ -0,0 +1,200 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { validatePack } from './author.js';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import yaml from 'js-yaml';
7
+
8
+ describe('validatePack — media validation', () => {
9
+ let tmpDir;
10
+
11
+ beforeEach(() => {
12
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teach-author-'));
13
+ });
14
+
15
+ afterEach(() => {
16
+ fs.rmSync(tmpDir, { recursive: true, force: true });
17
+ });
18
+
19
+ function createPack(modules = {}) {
20
+ const packDir = path.join(tmpDir, 'test-pack');
21
+ const modulesDir = path.join(packDir, 'modules');
22
+ fs.mkdirSync(modulesDir, { recursive: true });
23
+
24
+ const moduleNames = Object.keys(modules);
25
+ fs.writeFileSync(
26
+ path.join(packDir, 'pack.yaml'),
27
+ yaml.dump({ name: 'test-pack', version: '1.0.0', modules: moduleNames }),
28
+ 'utf-8',
29
+ );
30
+
31
+ for (const [name, meta] of Object.entries(modules)) {
32
+ const modDir = path.join(modulesDir, name);
33
+ fs.mkdirSync(modDir, { recursive: true });
34
+
35
+ const defaults = {
36
+ slug: name,
37
+ title: name,
38
+ description: 'Test module',
39
+ category: 'test',
40
+ difficulty: 'beginner',
41
+ tags: [],
42
+ xp: { read: 10 },
43
+ time: { read: 5 },
44
+ prerequisites: [],
45
+ related: [],
46
+ triggers: [],
47
+ };
48
+
49
+ fs.writeFileSync(
50
+ path.join(modDir, 'module.yaml'),
51
+ yaml.dump({ ...defaults, ...meta }),
52
+ 'utf-8',
53
+ );
54
+ fs.writeFileSync(path.join(modDir, 'content.md'), '# Content\n', 'utf-8');
55
+ }
56
+
57
+ return packDir;
58
+ }
59
+
60
+ it('passes with valid media entries', async () => {
61
+ const packDir = createPack({
62
+ 'media-mod': {
63
+ media: [
64
+ { url: 'https://example.com/img.png', type: 'image', alt: 'A diagram' },
65
+ { url: 'https://youtube.com/watch?v=abc', type: 'video', title: 'Tutorial' },
66
+ ],
67
+ },
68
+ });
69
+
70
+ const result = await validatePack(packDir);
71
+ expect(result.valid).toBe(true);
72
+ expect(result.errors).toHaveLength(0);
73
+ // No warnings for properly filled media entries
74
+ const mediaWarnings = result.warnings.filter(w => w.includes('media'));
75
+ expect(mediaWarnings).toHaveLength(0);
76
+ });
77
+
78
+ it('errors when media entry is missing url', async () => {
79
+ const packDir = createPack({
80
+ 'bad-media': {
81
+ media: [
82
+ { type: 'image', alt: 'Missing URL' },
83
+ ],
84
+ },
85
+ });
86
+
87
+ const result = await validatePack(packDir);
88
+ expect(result.errors.some(e => e.includes('media[0]') && e.includes('missing "url"'))).toBe(true);
89
+ });
90
+
91
+ it('warns when media url is not a full URL', async () => {
92
+ const packDir = createPack({
93
+ 'relative-url': {
94
+ media: [
95
+ { url: 'media/img.png', type: 'image', alt: 'Relative' },
96
+ ],
97
+ },
98
+ });
99
+
100
+ const result = await validatePack(packDir);
101
+ expect(result.warnings.some(w => w.includes('media[0]') && w.includes('full URL'))).toBe(true);
102
+ });
103
+
104
+ it('warns when media entry is missing type', async () => {
105
+ const packDir = createPack({
106
+ 'no-type': {
107
+ media: [
108
+ { url: 'https://example.com/img.png', alt: 'No type' },
109
+ ],
110
+ },
111
+ });
112
+
113
+ const result = await validatePack(packDir);
114
+ expect(result.warnings.some(w => w.includes('media[0]') && w.includes('missing "type"'))).toBe(true);
115
+ });
116
+
117
+ it('warns when image is missing alt text', async () => {
118
+ const packDir = createPack({
119
+ 'no-alt': {
120
+ media: [
121
+ { url: 'https://example.com/img.png', type: 'image' },
122
+ ],
123
+ },
124
+ });
125
+
126
+ const result = await validatePack(packDir);
127
+ expect(result.warnings.some(w => w.includes('media[0]') && w.includes('alt'))).toBe(true);
128
+ });
129
+
130
+ it('warns when audio/video is missing label or title', async () => {
131
+ const packDir = createPack({
132
+ 'no-label': {
133
+ media: [
134
+ { url: 'https://example.com/sound.mp3', type: 'audio' },
135
+ { url: 'https://youtube.com/watch?v=xyz', type: 'video' },
136
+ ],
137
+ },
138
+ });
139
+
140
+ const result = await validatePack(packDir);
141
+ expect(result.warnings.some(w => w.includes('media[0]') && w.includes('label'))).toBe(true);
142
+ expect(result.warnings.some(w => w.includes('media[1]') && w.includes('label'))).toBe(true);
143
+ });
144
+
145
+ it('does not warn for audio/video with label field', async () => {
146
+ const packDir = createPack({
147
+ 'with-label': {
148
+ media: [
149
+ { url: 'https://example.com/sound.mp3', type: 'audio', label: 'Achievement' },
150
+ ],
151
+ },
152
+ });
153
+
154
+ const result = await validatePack(packDir);
155
+ const mediaWarnings = result.warnings.filter(w => w.includes('media'));
156
+ expect(mediaWarnings).toHaveLength(0);
157
+ });
158
+
159
+ it('does not warn for audio/video with title field', async () => {
160
+ const packDir = createPack({
161
+ 'with-title': {
162
+ media: [
163
+ { url: 'https://youtube.com/watch?v=abc', type: 'video', title: 'Tutorial' },
164
+ ],
165
+ },
166
+ });
167
+
168
+ const result = await validatePack(packDir);
169
+ const mediaWarnings = result.warnings.filter(w => w.includes('media'));
170
+ expect(mediaWarnings).toHaveLength(0);
171
+ });
172
+
173
+ it('validates multiple media entries independently', async () => {
174
+ const packDir = createPack({
175
+ 'multi-media': {
176
+ media: [
177
+ { url: 'https://example.com/img.png', type: 'image', alt: 'Valid' },
178
+ { type: 'audio' }, // missing url
179
+ { url: 'https://example.com/img2.png', type: 'image' }, // missing alt
180
+ ],
181
+ },
182
+ });
183
+
184
+ const result = await validatePack(packDir);
185
+ expect(result.errors.some(e => e.includes('media[1]') && e.includes('missing "url"'))).toBe(true);
186
+ expect(result.warnings.some(w => w.includes('media[2]') && w.includes('alt'))).toBe(true);
187
+ // First entry should have no issues
188
+ expect(result.errors.some(e => e.includes('media[0]'))).toBe(false);
189
+ expect(result.warnings.some(w => w.includes('media[0]'))).toBe(false);
190
+ });
191
+
192
+ it('passes when module has no media section', async () => {
193
+ const packDir = createPack({
194
+ 'no-media': {},
195
+ });
196
+
197
+ const result = await validatePack(packDir);
198
+ expect(result.valid).toBe(true);
199
+ });
200
+ });
package/src/registry.js CHANGED
@@ -196,6 +196,16 @@ function detectCapabilities(modulePath) {
196
196
  if (existsSync(join(modulePath, 'exercises.md'))) caps.push('exercises');
197
197
  if (existsSync(join(modulePath, 'quiz.md'))) caps.push('quiz');
198
198
  if (existsSync(join(modulePath, 'quick-ref.md'))) caps.push('quick-ref');
199
+ if (existsSync(join(modulePath, 'resources.md'))) caps.push('resources');
200
+
201
+ // Check for media entries in module.yaml
202
+ try {
203
+ const raw = readFileSync(join(modulePath, 'module.yaml'), 'utf-8');
204
+ const meta = yaml.load(raw);
205
+ if (Array.isArray(meta?.media) && meta.media.length > 0) caps.push('media');
206
+ } catch {
207
+ // Ignore — module.yaml already read elsewhere
208
+ }
199
209
 
200
210
  return caps;
201
211
  }
@@ -290,6 +290,61 @@ describe('buildRegistry (filesystem)', () => {
290
290
  expect(mod.title).toBe('Fallback Module');
291
291
  });
292
292
 
293
+ it('should detect resources capability when resources.md exists', async () => {
294
+ const moduleDir = createModule('res-test');
295
+ fs.writeFileSync(path.join(moduleDir, 'resources.md'), '# Resources\n\n## Videos\n- [Example](https://example.com)\n', 'utf-8');
296
+
297
+ const { buildRegistry } = await import('./registry.js');
298
+ const registry = await buildRegistry(tmpDir);
299
+
300
+ const mod = registry.modules.find(m => m.slug === 'res-test');
301
+ expect(mod.capabilities).toContain('resources');
302
+ });
303
+
304
+ it('should not detect resources capability when resources.md is absent', async () => {
305
+ createModule('no-res-test');
306
+
307
+ const { buildRegistry } = await import('./registry.js');
308
+ const registry = await buildRegistry(tmpDir);
309
+
310
+ const mod = registry.modules.find(m => m.slug === 'no-res-test');
311
+ expect(mod.capabilities).not.toContain('resources');
312
+ });
313
+
314
+ it('should detect media capability when module.yaml has media entries', async () => {
315
+ createModule('media-test', {
316
+ media: [
317
+ { url: 'https://example.com/img.png', type: 'image', alt: 'Test image' },
318
+ ],
319
+ });
320
+
321
+ const { buildRegistry } = await import('./registry.js');
322
+ const registry = await buildRegistry(tmpDir);
323
+
324
+ const mod = registry.modules.find(m => m.slug === 'media-test');
325
+ expect(mod.capabilities).toContain('media');
326
+ });
327
+
328
+ it('should not detect media capability when media array is empty', async () => {
329
+ createModule('no-media-test', { media: [] });
330
+
331
+ const { buildRegistry } = await import('./registry.js');
332
+ const registry = await buildRegistry(tmpDir);
333
+
334
+ const mod = registry.modules.find(m => m.slug === 'no-media-test');
335
+ expect(mod.capabilities).not.toContain('media');
336
+ });
337
+
338
+ it('should not detect media capability when media field is absent', async () => {
339
+ createModule('absent-media-test');
340
+
341
+ const { buildRegistry } = await import('./registry.js');
342
+ const registry = await buildRegistry(tmpDir);
343
+
344
+ const mod = registry.modules.find(m => m.slug === 'absent-media-test');
345
+ expect(mod.capabilities).not.toContain('media');
346
+ });
347
+
293
348
  it('should skip directories without module.yaml', async () => {
294
349
  // Create a directory without module.yaml
295
350
  const emptyDir = path.join(tmpDir, 'packages', 'modules', 'no-yaml');