@shaykec/claude-teach 0.6.0 → 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 +2 -2
- package/src/author.js +32 -1
- package/src/author.test.js +200 -0
- package/src/registry.js +10 -0
- package/src/registry.test.js +55 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shaykec/claude-teach",
|
|
3
|
-
"version": "0.6.
|
|
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",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"chalk": "^5.3.0",
|
|
18
18
|
"@shaykec/bridge": "0.4.0",
|
|
19
19
|
"@shaykec/shared": "0.1.0",
|
|
20
|
-
"@shaykec/plugin": "0.2.
|
|
20
|
+
"@shaykec/plugin": "0.2.1",
|
|
21
21
|
"@shaykec/extension": "0.1.0"
|
|
22
22
|
},
|
|
23
23
|
"publishConfig": {
|
package/src/author.js
CHANGED
|
@@ -272,7 +272,38 @@ export async function validatePack(packPath, builtinSlugs = []) {
|
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
// 4. Validate
|
|
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
|
}
|
package/src/registry.test.js
CHANGED
|
@@ -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');
|