tessera-learn 0.0.13 → 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.
Files changed (56) hide show
  1. package/AGENTS.md +1744 -0
  2. package/README.md +2 -2
  3. package/dist/{validation-B-xTvM9B.js → audit-CzKAXy3Y.js} +591 -268
  4. package/dist/audit-CzKAXy3Y.js.map +1 -0
  5. package/dist/build-commands-D101M_qb.js +27 -0
  6. package/dist/build-commands-D101M_qb.js.map +1 -0
  7. package/dist/inline-config-DYHT51G8.js +29 -0
  8. package/dist/inline-config-DYHT51G8.js.map +1 -0
  9. package/dist/plugin/cli.d.ts +5 -1
  10. package/dist/plugin/cli.d.ts.map +1 -0
  11. package/dist/plugin/cli.js +108 -15
  12. package/dist/plugin/cli.js.map +1 -1
  13. package/dist/plugin/index.d.ts.map +1 -1
  14. package/dist/plugin/index.js +2 -763
  15. package/dist/plugin-y35ym9A3.js +744 -0
  16. package/dist/plugin-y35ym9A3.js.map +1 -0
  17. package/package.json +12 -9
  18. package/src/components/FillInTheBlank.svelte +2 -2
  19. package/src/components/Matching.svelte +2 -2
  20. package/src/components/MultipleChoice.svelte +2 -2
  21. package/src/components/RevealModal.svelte +48 -103
  22. package/src/components/Sorting.svelte +2 -2
  23. package/src/components/util.ts +9 -0
  24. package/src/plugin/a11y/audit.ts +35 -8
  25. package/src/plugin/a11y-cli.ts +35 -22
  26. package/src/plugin/ast.ts +276 -0
  27. package/src/plugin/build-commands.ts +25 -0
  28. package/src/plugin/cli.ts +53 -21
  29. package/src/plugin/index.ts +87 -122
  30. package/src/plugin/inline-config.ts +43 -0
  31. package/src/plugin/manifest.ts +103 -136
  32. package/src/plugin/package-root.ts +24 -0
  33. package/src/plugin/quiz.ts +8 -9
  34. package/src/plugin/validate-cli.ts +30 -0
  35. package/src/plugin/validation.ts +152 -244
  36. package/src/runtime/App.svelte +11 -97
  37. package/src/runtime/Sidebar.svelte +3 -1
  38. package/src/runtime/adapters/cmi5.ts +6 -10
  39. package/src/runtime/adapters/format.ts +6 -0
  40. package/src/runtime/adapters/retry.ts +1 -1
  41. package/src/runtime/adapters/scorm2004.ts +2 -4
  42. package/src/runtime/branding.ts +90 -0
  43. package/src/runtime/defaults.ts +3 -0
  44. package/src/runtime/hooks.svelte.ts +16 -53
  45. package/src/runtime/interaction-format.ts +3 -8
  46. package/src/runtime/progress.svelte.ts +47 -83
  47. package/src/runtime/xapi/derive-actor.ts +41 -48
  48. package/src/runtime/xapi/publisher.ts +14 -14
  49. package/src/runtime/xapi/setup.ts +39 -46
  50. package/dist/audit-BBJpQGqb.js +0 -204
  51. package/dist/audit-BBJpQGqb.js.map +0 -1
  52. package/dist/plugin/a11y-cli.d.ts +0 -1
  53. package/dist/plugin/a11y-cli.js +0 -36
  54. package/dist/plugin/a11y-cli.js.map +0 -1
  55. package/dist/plugin/index.js.map +0 -1
  56. package/dist/validation-B-xTvM9B.js.map +0 -1
@@ -1,6 +1,11 @@
1
1
  import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
2
2
  import { resolve, basename, extname } from 'node:path';
3
3
  import JSON5 from 'json5';
4
+ import {
5
+ clearParseCache,
6
+ defaultExportObjectLiteral,
7
+ pageConfigLiteral,
8
+ } from './ast.js';
4
9
  import type { CourseConfig, QuizConfig } from '../runtime/types.js';
5
10
 
6
11
  // ---------- Types ----------
@@ -85,29 +90,22 @@ export function deriveSlug(name: string, isFile = false): string {
85
90
  return stripPrefix(name);
86
91
  }
87
92
 
88
- /** Matches both Svelte 5 `<script module>` and legacy `<script context="module">`. */
89
- export const MODULE_SCRIPT_RE =
90
- /<script\s+(?:context\s*=\s*["']module["']|module)[^>]*>([\s\S]*?)<\/script>/;
91
-
92
- /** Matches `export const pageConfig =` (RHS is read separately). */
93
- export const PAGE_CONFIG_EXPORT_RE = /export\s+const\s+pageConfig\s*=\s*/;
94
-
95
- /** Matches `export default ` (RHS is read separately). */
96
- const DEFAULT_EXPORT_RE = /export\s+default\s*/;
93
+ export type DefaultExportLiteralResult =
94
+ | { kind: 'literal'; text: string }
95
+ | { kind: 'none' }
96
+ | { kind: 'invalid' }
97
+ | { kind: 'parse-error' };
97
98
 
98
99
  /**
99
- * Locate `export default { ... }` and return the object literal substring,
100
- * or null if no balanced object literal follows the `export default` keyword.
101
- * Used by both manifest extraction and project validation.
100
+ * Locate `export default { ... }` and return its object-literal text. Returns
101
+ * a discriminated result so callers can tell parse failure from a missing or
102
+ * non-literal default export. Used by both manifest extraction and project
103
+ * validation.
102
104
  */
103
105
  export function extractDefaultExportObjectLiteral(
104
106
  source: string,
105
- ): string | null {
106
- const match = source.match(DEFAULT_EXPORT_RE);
107
- if (!match || match.index === undefined) return null;
108
- const startIndex = source.indexOf('{', match.index);
109
- if (startIndex < 0) return null;
110
- return extractObjectLiteral(source, startIndex);
107
+ ): DefaultExportLiteralResult {
108
+ return defaultExportObjectLiteral(source);
111
109
  }
112
110
 
113
111
  export type CourseConfigRead =
@@ -128,12 +126,14 @@ export type CourseConfigRead =
128
126
  export function readCourseConfig(projectRoot: string): CourseConfigRead {
129
127
  const configPath = resolve(projectRoot, 'course.config.js');
130
128
  if (!existsSync(configPath)) return { ok: false, reason: 'missing' };
131
- const objectStr = extractDefaultExportObjectLiteral(
129
+ const result = extractDefaultExportObjectLiteral(
132
130
  readSourceFileCached(configPath),
133
131
  );
134
- if (!objectStr) return { ok: false, reason: 'no-export' };
132
+ if (result.kind === 'parse-error')
133
+ return { ok: false, reason: 'parse-error' };
134
+ if (result.kind !== 'literal') return { ok: false, reason: 'no-export' };
135
135
  try {
136
- return { ok: true, config: JSON5.parse(objectStr) };
136
+ return { ok: true, config: JSON5.parse(result.text) };
137
137
  } catch (error) {
138
138
  return { ok: false, reason: 'parse-error', error };
139
139
  }
@@ -150,13 +150,13 @@ export function readMetaFile(metaPath: string): {
150
150
  } {
151
151
  if (!existsSync(metaPath)) return {};
152
152
 
153
- const objectStr = extractDefaultExportObjectLiteral(
153
+ const result = extractDefaultExportObjectLiteral(
154
154
  readSourceFileCached(metaPath),
155
155
  );
156
- if (!objectStr) return {};
156
+ if (result.kind !== 'literal') return {};
157
157
 
158
158
  try {
159
- return JSON5.parse(objectStr);
159
+ return JSON5.parse(result.text);
160
160
  } catch {
161
161
  return {};
162
162
  }
@@ -178,30 +178,12 @@ export type PageConfigParseResult =
178
178
  export function parsePageConfigFromSource(
179
179
  content: string,
180
180
  ): PageConfigParseResult {
181
- const moduleScriptMatch = content.match(MODULE_SCRIPT_RE);
182
- if (!moduleScriptMatch) return { kind: 'none' };
183
-
184
- const scriptContent = moduleScriptMatch[1];
185
-
186
- const configMatch = scriptContent.match(PAGE_CONFIG_EXPORT_RE);
187
- if (!configMatch || configMatch.index === undefined) return { kind: 'none' };
188
-
189
- const afterExport = scriptContent
190
- .slice(configMatch.index + configMatch[0].length)
191
- .trimStart();
192
- // pageConfig assigned to something other than an object literal — flag as invalid.
193
- if (!afterExport.startsWith('{')) return { kind: 'invalid' };
194
-
195
- const startIndex = scriptContent.indexOf(
196
- '{',
197
- configMatch.index + configMatch[0].length,
198
- );
199
- if (startIndex < 0) return { kind: 'invalid' };
200
- const objectStr = extractObjectLiteral(scriptContent, startIndex);
201
- if (!objectStr) return { kind: 'invalid' };
181
+ const literal = pageConfigLiteral(content);
182
+ if (literal.kind === 'none') return { kind: 'none' };
183
+ if (literal.kind === 'invalid') return { kind: 'invalid' };
202
184
 
203
185
  try {
204
- return { kind: 'ok', value: JSON5.parse(objectStr) };
186
+ return { kind: 'ok', value: JSON5.parse(literal.text) };
205
187
  } catch {
206
188
  return { kind: 'invalid' };
207
189
  }
@@ -223,74 +205,6 @@ export function extractPageConfig(filePath: string): {
223
205
  return {};
224
206
  }
225
207
 
226
- /**
227
- * Extract a balanced `{...}` or `[...]` span starting at the opening bracket,
228
- * skipping strings and comments. Returns the substring (inclusive) or null if
229
- * the open char is wrong or no matching close is found. Shared by manifest
230
- * extraction, _meta/pageConfig parsing, and the validator's tag-prop parser.
231
- */
232
- export function extractObjectLiteral(
233
- source: string,
234
- startIndex: number,
235
- ): string | null {
236
- const open = source[startIndex];
237
- if (open !== '{' && open !== '[') return null;
238
-
239
- let depth = 0;
240
- let inString: string | null = null;
241
- let escaped = false;
242
-
243
- for (let i = startIndex; i < source.length; i++) {
244
- const char = source[i];
245
-
246
- if (escaped) {
247
- escaped = false;
248
- continue;
249
- }
250
-
251
- if (char === '\\' && inString) {
252
- escaped = true;
253
- continue;
254
- }
255
-
256
- if (inString) {
257
- if (char === inString) {
258
- inString = null;
259
- }
260
- continue;
261
- }
262
-
263
- if (char === '"' || char === "'" || char === '`') {
264
- inString = char;
265
- continue;
266
- }
267
-
268
- // Skip single-line comments
269
- if (char === '/' && i + 1 < source.length && source[i + 1] === '/') {
270
- const newline = source.indexOf('\n', i);
271
- i = newline === -1 ? source.length : newline;
272
- continue;
273
- }
274
-
275
- // Skip multi-line comments
276
- if (char === '/' && i + 1 < source.length && source[i + 1] === '*') {
277
- const end = source.indexOf('*/', i + 2);
278
- i = end === -1 ? source.length : end + 1;
279
- continue;
280
- }
281
-
282
- if (char === '{' || char === '[') depth++;
283
- if (char === '}' || char === ']') {
284
- depth--;
285
- if (depth === 0) {
286
- return source.slice(startIndex, i + 1);
287
- }
288
- }
289
- }
290
-
291
- return null;
292
- }
293
-
294
208
  /**
295
209
  * Get sorted subdirectories of a given path.
296
210
  */
@@ -314,22 +228,73 @@ function getSvelteFiles(dirPath: string): string[] {
314
228
  .sort();
315
229
  }
316
230
 
231
+ // ---------- Course structure walker ----------
232
+
233
+ export interface WalkedLesson {
234
+ /** Lesson directory name, or null for a section's implicit flat lesson. */
235
+ name: string | null;
236
+ /** Directory holding the `.svelte` files (the section dir for a flat lesson). */
237
+ dir: string;
238
+ /** `_meta.js` path governing this lesson's title and page order. */
239
+ metaPath: string;
240
+ /** Sorted raw `.svelte` filenames in `dir` (pre-ordering). */
241
+ files: string[];
242
+ }
243
+
244
+ export interface WalkedSection {
245
+ name: string;
246
+ dir: string;
247
+ metaPath: string;
248
+ lessons: WalkedLesson[];
249
+ }
250
+
251
+ /**
252
+ * Enumerate the course's section → lesson → file structure. Section-level
253
+ * `.svelte` files become an implicit flat lesson (`name: null`) ordered before
254
+ * the section's explicit lesson directories. Shared by manifest generation and
255
+ * build-time validation so the two never disagree on which files are pages.
256
+ */
257
+ export function walkPages(pagesDir: string): WalkedSection[] {
258
+ const sections: WalkedSection[] = [];
259
+ for (const sectionName of getSortedDirs(pagesDir)) {
260
+ const dir = resolve(pagesDir, sectionName);
261
+ const metaPath = resolve(dir, '_meta.js');
262
+ const lessons: WalkedLesson[] = [];
263
+
264
+ const flatFiles = getSvelteFiles(dir);
265
+ if (flatFiles.length > 0) {
266
+ lessons.push({ name: null, dir, metaPath, files: flatFiles });
267
+ }
268
+
269
+ for (const lessonName of getSortedDirs(dir)) {
270
+ const lessonDir = resolve(dir, lessonName);
271
+ lessons.push({
272
+ name: lessonName,
273
+ dir: lessonDir,
274
+ metaPath: resolve(lessonDir, '_meta.js'),
275
+ files: getSvelteFiles(lessonDir),
276
+ });
277
+ }
278
+
279
+ sections.push({ name: sectionName, dir, metaPath, lessons });
280
+ }
281
+ return sections;
282
+ }
283
+
317
284
  // ---------- Main ----------
318
285
 
319
286
  /**
320
287
  * Generate a course manifest by scanning the pages/ directory.
321
288
  */
322
289
  export function generateManifest(pagesDir: string): Manifest {
290
+ clearParseCache();
323
291
  const sections: ManifestSection[] = [];
324
292
  const flatPages: ManifestPage[] = [];
325
293
  let pageIndex = 0;
326
294
 
327
- const sectionDirs = getSortedDirs(pagesDir);
328
-
329
- for (const sectionName of sectionDirs) {
330
- const sectionPath = resolve(pagesDir, sectionName);
331
- const sectionMeta = readMetaFile(resolve(sectionPath, '_meta.js'));
332
- const sectionSlug = deriveSlug(sectionName);
295
+ for (const walkedSection of walkPages(pagesDir)) {
296
+ const sectionMeta = readMetaFile(walkedSection.metaPath);
297
+ const sectionSlug = deriveSlug(walkedSection.name);
333
298
 
334
299
  const section: ManifestSection = {
335
300
  title: sectionMeta.title || titleCase(sectionSlug),
@@ -337,25 +302,29 @@ export function generateManifest(pagesDir: string): Manifest {
337
302
  lessons: [],
338
303
  };
339
304
 
340
- const lessonDirs = getSortedDirs(sectionPath);
341
-
342
- for (const lessonName of lessonDirs) {
343
- const lessonPath = resolve(sectionPath, lessonName);
344
- const lessonMeta = readMetaFile(resolve(lessonPath, '_meta.js'));
345
- const lessonSlug = deriveSlug(lessonName);
305
+ for (const walkedLesson of walkedSection.lessons) {
306
+ // The flat lesson uses the section _meta for ordering and has no title —
307
+ // the sidebar renders its pages without a lesson header.
308
+ const isFlat = walkedLesson.name === null;
309
+ const lessonMeta = isFlat
310
+ ? sectionMeta
311
+ : readMetaFile(walkedLesson.metaPath);
312
+ const lessonSlug = isFlat ? sectionSlug : deriveSlug(walkedLesson.name!);
313
+ const relDir = isFlat
314
+ ? `/pages/${walkedSection.name}`
315
+ : `/pages/${walkedSection.name}/${walkedLesson.name}`;
346
316
 
347
317
  const lesson: ManifestLesson = {
348
- title: lessonMeta.title || titleCase(lessonSlug),
318
+ title: isFlat ? '' : lessonMeta.title || titleCase(lessonSlug),
349
319
  slug: lessonSlug,
350
320
  pages: [],
351
321
  };
352
322
 
353
- // Determine page order
354
- const allSvelteFiles = getSvelteFiles(lessonPath);
355
- const orderedFiles = orderPageFiles(allSvelteFiles, lessonMeta.pages);
356
-
357
- for (const fileName of orderedFiles) {
358
- const filePath = resolve(lessonPath, fileName);
323
+ for (const fileName of orderPageFiles(
324
+ walkedLesson.files,
325
+ lessonMeta.pages,
326
+ )) {
327
+ const filePath = resolve(walkedLesson.dir, fileName);
359
328
  const pageSlug = deriveSlug(fileName, true);
360
329
 
361
330
  let pageConfig: {
@@ -371,13 +340,11 @@ export function generateManifest(pagesDir: string): Manifest {
371
340
  console.warn(`[tessera warning] ${(e as Error).message}`);
372
341
  }
373
342
 
374
- const relativePath = `/pages/${sectionName}/${lessonName}/${fileName}`;
375
-
376
343
  const page: ManifestPage = {
377
344
  index: pageIndex,
378
345
  title: pageConfig.title || titleCase(pageSlug),
379
346
  slug: pageSlug,
380
- importPath: relativePath,
347
+ importPath: `${relDir}/${fileName}`,
381
348
  quiz: pageConfig.quiz || null,
382
349
  ...(pageConfig.completesOn === 'view'
383
350
  ? { completesOn: 'view' as const }
@@ -0,0 +1,24 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+
4
+ // Walk up from this module to the tessera-learn package root (the dir holding
5
+ // its package.json). Resolving by directory depth — resolve(dirname, '..', '..')
6
+ // — is brittle: tsdown may emit plugin code at dist/plugin/ or hoist it into a
7
+ // shared chunk at dist/, and those differ by one level. The package ships src/
8
+ // and styles/, so its package.json is the stable anchor for both.
9
+ export function resolvePackageRoot(): string {
10
+ const dir = import.meta.dirname;
11
+ for (let up = dir; up !== dirname(up); up = dirname(up)) {
12
+ const pkgPath = resolve(up, 'package.json');
13
+ if (existsSync(pkgPath)) {
14
+ try {
15
+ const { name } = JSON.parse(readFileSync(pkgPath, 'utf-8'));
16
+ if (name === 'tessera-learn') return up;
17
+ } catch {
18
+ // unreadable / non-JSON package.json — keep walking up
19
+ }
20
+ }
21
+ }
22
+ // Fallback to the historical depth assumption (dist/plugin → package root).
23
+ return resolve(dir, '..', '..');
24
+ }
@@ -1,16 +1,15 @@
1
1
  import type { Plugin } from 'vite';
2
- import { resolve, dirname } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
2
+ import { resolve } from 'node:path';
4
3
  import { createOverridePlugin } from './override-plugin.js';
5
-
6
- const __filename = fileURLToPath(import.meta.url);
7
- const __dirname = dirname(__filename);
4
+ import { resolvePackageRoot } from './package-root.js';
8
5
 
9
6
  export function tesseraQuizPlugin(): Plugin {
10
- // The plugin lives in `dist/plugin/quiz.js` after build and `src/plugin/quiz.ts`
11
- // in source — both put `Quiz.svelte` two levels up under `src/components/`.
12
- const packageRoot = resolve(__dirname, '..', '..');
13
- const builtinQuiz = resolve(packageRoot, 'src', 'components', 'Quiz.svelte');
7
+ const builtinQuiz = resolve(
8
+ resolvePackageRoot(),
9
+ 'src',
10
+ 'components',
11
+ 'Quiz.svelte',
12
+ );
14
13
  return createOverridePlugin({
15
14
  name: 'tessera:quiz',
16
15
  virtualId: 'virtual:tessera-quiz',
@@ -0,0 +1,30 @@
1
+ import { validateProject, reportValidationIssues } from './validation.js';
2
+
3
+ export function runValidate(projectRoot: string): number {
4
+ const { errors, warnings } = validateProject(projectRoot);
5
+
6
+ reportValidationIssues({ errors, warnings });
7
+
8
+ if (errors.length > 0) {
9
+ const summary =
10
+ `Validation failed with ${errors.length} error(s)` +
11
+ (warnings.length > 0 ? ` and ${warnings.length} warning(s)` : '') +
12
+ '.';
13
+ console.error(`\n\x1b[31m${summary}\x1b[0m`);
14
+ return 1;
15
+ }
16
+
17
+ if (warnings.length > 0) {
18
+ console.log(
19
+ `\n\x1b[33mValidation passed with ${warnings.length} warning(s).\x1b[0m`,
20
+ );
21
+ } else {
22
+ console.log(
23
+ '\x1b[32m[tessera]\x1b[0m Validation passed — no issues found.',
24
+ );
25
+ }
26
+ console.log(
27
+ '\x1b[2m[tessera] Static checks only. For a full runtime accessibility audit, run: tessera a11y\x1b[0m',
28
+ );
29
+ return 0;
30
+ }