tessera-learn 0.2.3 → 0.4.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 (57) hide show
  1. package/AGENTS.md +50 -21
  2. package/README.md +2 -2
  3. package/dist/{audit--fSWIOgK.js → audit-DsYqXbqm.js} +282 -197
  4. package/dist/audit-DsYqXbqm.js.map +1 -0
  5. package/dist/{build-commands-Qyrlsp3n.js → build-commands-BFuiAxaR.js} +4 -4
  6. package/dist/build-commands-BFuiAxaR.js.map +1 -0
  7. package/dist/{inline-config-DqAKsCNl.js → inline-config-DVvOCKht.js} +6 -6
  8. package/dist/inline-config-DVvOCKht.js.map +1 -0
  9. package/dist/plugin/cli.d.ts +5 -1
  10. package/dist/plugin/cli.d.ts.map +1 -1
  11. package/dist/plugin/cli.js +91 -49
  12. package/dist/plugin/cli.js.map +1 -1
  13. package/dist/plugin/index.d.ts +287 -2
  14. package/dist/plugin/index.d.ts.map +1 -1
  15. package/dist/plugin/index.js +3 -3
  16. package/dist/{plugin-B-aiL9-V.js → plugin-BuMiDTmU.js} +145 -111
  17. package/dist/plugin-BuMiDTmU.js.map +1 -0
  18. package/package.json +7 -7
  19. package/src/components/DefaultLayout.svelte +2 -5
  20. package/src/components/MultipleChoice.svelte +1 -2
  21. package/src/components/Quiz.svelte +18 -26
  22. package/src/plugin/ast.ts +9 -2
  23. package/src/plugin/build-commands.ts +7 -4
  24. package/src/plugin/cli.ts +96 -46
  25. package/src/plugin/csp.ts +59 -0
  26. package/src/plugin/duplicate-cli.ts +37 -1
  27. package/src/plugin/export.ts +56 -27
  28. package/src/plugin/index.ts +138 -93
  29. package/src/plugin/inline-config.ts +4 -2
  30. package/src/plugin/manifest.ts +24 -23
  31. package/src/plugin/new-cli.ts +2 -0
  32. package/src/plugin/validate-cli.ts +5 -2
  33. package/src/plugin/validation.ts +255 -238
  34. package/src/runtime/App.svelte +14 -9
  35. package/src/runtime/Sidebar.svelte +3 -1
  36. package/src/runtime/adapters/cmi5.ts +59 -402
  37. package/src/runtime/adapters/discovery.ts +11 -0
  38. package/src/runtime/adapters/index.ts +27 -60
  39. package/src/runtime/adapters/lms-error.ts +61 -0
  40. package/src/runtime/adapters/scorm-base.ts +15 -14
  41. package/src/runtime/adapters/scorm12.ts +6 -25
  42. package/src/runtime/adapters/scorm2004.ts +12 -54
  43. package/src/runtime/adapters/web.ts +11 -4
  44. package/src/runtime/adapters/xapi-launch-base.ts +346 -0
  45. package/src/runtime/adapters/xapi.ts +26 -0
  46. package/src/runtime/fingerprint.ts +28 -0
  47. package/src/runtime/interaction-format.ts +0 -1
  48. package/src/runtime/persistence.ts +4 -0
  49. package/src/runtime/types.ts +22 -1
  50. package/src/runtime/xapi/publisher.ts +16 -15
  51. package/src/runtime/xapi/setup.ts +24 -15
  52. package/src/virtual.d.ts +4 -1
  53. package/templates/course/course.config.js +1 -0
  54. package/dist/audit--fSWIOgK.js.map +0 -1
  55. package/dist/build-commands-Qyrlsp3n.js.map +0 -1
  56. package/dist/inline-config-DqAKsCNl.js.map +0 -1
  57. package/dist/plugin-B-aiL9-V.js.map +0 -1
@@ -10,7 +10,12 @@ import {
10
10
  cpSync,
11
11
  mkdirSync,
12
12
  } from 'node:fs';
13
- import { generateManifest, readCourseConfig } from './manifest.js';
13
+ import {
14
+ generateManifest,
15
+ readCourseConfig,
16
+ readResolvedConfig,
17
+ type CourseConfigRead,
18
+ } from './manifest.js';
14
19
  import type { Manifest } from './manifest.js';
15
20
  import type { CourseConfig } from '../runtime/types.js';
16
21
  import {
@@ -25,6 +30,7 @@ import {
25
30
  isIgnored,
26
31
  type A11ySettings,
27
32
  } from './validation.js';
33
+ import { buildCsp } from './csp.js';
28
34
  import { runExport } from './export.js';
29
35
  import { tesseraLayoutPlugin } from './layout.js';
30
36
  import { tesseraQuizPlugin } from './quiz.js';
@@ -114,7 +120,8 @@ function virtualModule(
114
120
  };
115
121
  }
116
122
 
117
- export function tesseraPlugin() {
123
+ export function tesseraPlugin(options: { standardOverride?: string } = {}) {
124
+ const { standardOverride } = options;
118
125
  const manifestRef: { current: Manifest | null; root: string } = {
119
126
  current: null,
120
127
  root: '',
@@ -145,18 +152,18 @@ export function tesseraPlugin() {
145
152
  },
146
153
  }),
147
154
  tesseraA11yCompilerPlugin(a11y),
148
- tesseraValidationPlugin(),
149
- tesseraEntryPlugin(),
155
+ tesseraValidationPlugin(standardOverride),
156
+ tesseraEntryPlugin(standardOverride),
150
157
  tesseraConfigDefaultsPlugin(),
151
- tesseraConfigPlugin(),
158
+ tesseraConfigPlugin(standardOverride),
152
159
  tesseraPagesPlugin(),
153
160
  tesseraManifestPlugin(manifestRef),
154
161
  tesseraLayoutPlugin(),
155
162
  tesseraQuizPlugin(),
156
- tesseraAdapterPlugin(),
157
- tesseraXAPISetupPlugin(),
163
+ tesseraAdapterPlugin(standardOverride),
164
+ tesseraXAPISetupPlugin(standardOverride),
158
165
  tesseraFirstPagePreloadPlugin(manifestRef),
159
- tesseraExportPlugin(),
166
+ tesseraExportPlugin(standardOverride),
160
167
  ];
161
168
  }
162
169
 
@@ -167,7 +174,7 @@ const RESOLVED_ENTRY_ID = '\0' + VIRTUAL_ENTRY_ID;
167
174
  const VIRTUAL_MAIN_ID = '/virtual:tessera-main';
168
175
  const RESOLVED_MAIN_ID = '\0virtual:tessera-main';
169
176
 
170
- function tesseraEntryPlugin(): Plugin {
177
+ function tesseraEntryPlugin(standardOverride?: string): Plugin {
171
178
  const runtimeDir = resolveRuntimeDir();
172
179
  const stylesDir = resolveStylesDir();
173
180
  const appSveltePath = resolve(runtimeDir, 'App.svelte');
@@ -188,9 +195,10 @@ function tesseraEntryPlugin(): Plugin {
188
195
  // For build mode: write index.html so Rollup can find it
189
196
  buildStart() {
190
197
  if (isBuild) {
198
+ const read = readResolvedConfig(projectRoot, standardOverride);
191
199
  writeFileSync(
192
200
  resolve(projectRoot, 'index.html'),
193
- generateIndexHtml(readLanguage(projectRoot)),
201
+ generateIndexHtml(readLanguage(read), cspMeta(read)),
194
202
  'utf-8',
195
203
  );
196
204
  }
@@ -221,7 +229,9 @@ function tesseraEntryPlugin(): Plugin {
221
229
  return () => {
222
230
  server.middlewares.use(async (req, res, next) => {
223
231
  if (req.url === '/' || req.url === '/index.html') {
224
- const html = generateIndexHtml(readLanguage(projectRoot));
232
+ const html = generateIndexHtml(
233
+ readLanguage(readCourseConfig(projectRoot)),
234
+ );
225
235
  const transformed = await server.transformIndexHtml(req.url, html);
226
236
  res.setHeader('Content-Type', 'text/html');
227
237
  res.statusCode = 200;
@@ -252,18 +262,28 @@ function tesseraEntryPlugin(): Plugin {
252
262
  // 'en' fallback applied here: the config default-merge runs later than buildStart.
253
263
  // Only a validated BCP-47 tag is interpolated into <html lang>, so a malformed
254
264
  // value (caught separately as a warning) can't ship a broken attribute.
255
- function readLanguage(projectRoot: string): string {
256
- const read = readCourseConfig(projectRoot);
265
+ function readLanguage(read: CourseConfigRead): string {
257
266
  const lang = read.ok ? read.config.language : undefined;
258
267
  return isPlausibleLanguageTag(lang) ? lang : 'en';
259
268
  }
260
269
 
261
- function generateIndexHtml(lang: string): string {
270
+ // Web export only — never on LMS packages (whose iframe JS bridges a meta CSP
271
+ // could break) and never on the dev server (a meta connect-src would block
272
+ // Vite's HMR websocket). `export.csp` extends the baseline per-directive, or
273
+ // `false` drops the meta for deployments that set a CSP header themselves.
274
+ function cspMeta(read: CourseConfigRead & { standard: string }): string {
275
+ if (read.standard !== 'web') return '';
276
+ const csp = read.ok ? read.config.export?.csp : undefined;
277
+ if (csp === false) return '';
278
+ return `\n <meta http-equiv="Content-Security-Policy" content="${buildCsp(csp)}" />`;
279
+ }
280
+
281
+ function generateIndexHtml(lang: string, csp = ''): string {
262
282
  return `<!DOCTYPE html>
263
283
  <html lang="${lang}">
264
284
  <head>
265
285
  <meta charset="UTF-8" />
266
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
286
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />${csp}
267
287
  <title>Tessera Course</title>
268
288
  </head>
269
289
  <body>
@@ -326,6 +346,12 @@ function completionDefaults(mode: string | undefined): {
326
346
  if (mode === 'manual') {
327
347
  return { completion: { mode: 'manual' }, passingScore: 0 };
328
348
  }
349
+ if (mode === 'quiz') {
350
+ return {
351
+ completion: { mode: 'quiz' },
352
+ passingScore: DEFAULT_PASSING_SCORE,
353
+ };
354
+ }
329
355
  return {
330
356
  completion: {
331
357
  mode: 'percentage',
@@ -353,27 +379,34 @@ function tesseraConfigDefaultsPlugin(): Plugin {
353
379
  };
354
380
  }
355
381
 
356
- function tesseraConfigPlugin(): Plugin {
382
+ /** Fill runtime defaults into a parsed course.config.js. Exported for tests. */
383
+ export function mergeCourseConfig(userConfig: Partial<CourseConfig>) {
384
+ const { completion, passingScore } = completionDefaults(
385
+ userConfig.completion?.mode,
386
+ );
387
+ return {
388
+ ...userConfig,
389
+ title: userConfig.title || 'Untitled Course',
390
+ resume: userConfig.resume ?? 'auto',
391
+ navigation: { mode: 'free', ...userConfig.navigation },
392
+ completion: { ...completion, ...userConfig.completion },
393
+ scoring: { passingScore, ...userConfig.scoring },
394
+ export: { standard: 'web', ...userConfig.export },
395
+ };
396
+ }
397
+
398
+ function tesseraConfigPlugin(standardOverride?: string): Plugin {
357
399
  return virtualModule(
358
400
  'tessera:config',
359
401
  VIRTUAL_CONFIG_ID,
360
402
  function ({ projectRoot }) {
361
403
  const configPath = resolve(projectRoot, 'course.config.js');
362
404
  if (existsSync(configPath)) this.addWatchFile(configPath);
363
- const read = readCourseConfig(projectRoot);
405
+ // The runtime reads export.standard too, so readResolvedConfig must apply
406
+ // the override here — the bundled config, not just the manifest/adapter.
407
+ const read = readResolvedConfig(projectRoot, standardOverride);
364
408
  const userConfig: Partial<CourseConfig> = read.ok ? read.config : {};
365
- const { completion, passingScore } = completionDefaults(
366
- userConfig.completion?.mode,
367
- );
368
- const merged = {
369
- title: userConfig.title || 'Untitled Course',
370
- ...userConfig,
371
- navigation: { mode: 'free', ...userConfig.navigation },
372
- completion: { ...completion, ...userConfig.completion },
373
- scoring: { passingScore, ...userConfig.scoring },
374
- export: { standard: 'web', ...userConfig.export },
375
- };
376
- return `export default ${JSON.stringify(merged)};`;
409
+ return `export default ${JSON.stringify(mergeCourseConfig(userConfig))};`;
377
410
  },
378
411
  );
379
412
  }
@@ -413,7 +446,7 @@ function tesseraPagesPlugin(): Plugin {
413
446
 
414
447
  // ---------- Validation Plugin ----------
415
448
 
416
- function tesseraValidationPlugin(): Plugin {
449
+ function tesseraValidationPlugin(standardOverride?: string): Plugin {
417
450
  let projectRoot: string;
418
451
  let isBuild = false;
419
452
 
@@ -426,14 +459,14 @@ function tesseraValidationPlugin(): Plugin {
426
459
  isBuild = config.command === 'build';
427
460
  // Run validation during dev (configResolved fires before server starts)
428
461
  if (!isBuild) {
429
- runValidation(projectRoot);
462
+ runValidation(projectRoot, standardOverride);
430
463
  }
431
464
  },
432
465
 
433
466
  buildStart() {
434
467
  // Run validation during build (buildStart fires once before bundling)
435
468
  if (isBuild) {
436
- runValidation(projectRoot);
469
+ runValidation(projectRoot, standardOverride);
437
470
  }
438
471
  },
439
472
  };
@@ -471,8 +504,8 @@ function tesseraA11yCompilerPlugin(a11y: A11yCompilerState): Plugin {
471
504
  };
472
505
  }
473
506
 
474
- function runValidation(projectRoot: string): void {
475
- const result = validateProject(projectRoot);
507
+ function runValidation(projectRoot: string, standardOverride?: string): void {
508
+ const result = validateProject(projectRoot, standardOverride);
476
509
  reportValidationIssues(result);
477
510
  if (result.errors.length > 0) {
478
511
  throw new Error(
@@ -483,7 +516,7 @@ function runValidation(projectRoot: string): void {
483
516
 
484
517
  // ---------- Export Plugin ----------
485
518
 
486
- function tesseraExportPlugin(): Plugin {
519
+ function tesseraExportPlugin(standardOverride?: string): Plugin {
487
520
  let projectRoot: string;
488
521
  let isBuild = false;
489
522
 
@@ -500,7 +533,7 @@ function tesseraExportPlugin(): Plugin {
500
533
  if (!isBuild) return;
501
534
  if (isAuditBuild()) return;
502
535
 
503
- const read = readCourseConfig(projectRoot);
536
+ const read = readResolvedConfig(projectRoot, standardOverride);
504
537
  if (!read.ok) {
505
538
  // Validation already required a parseable course.config.js — getting
506
539
  // here means it vanished or broke mid-build. Surface that loudly
@@ -611,7 +644,8 @@ function tesseraManifestPlugin(manifestRef: {
611
644
  value === Infinity ? 1e9 : value,
612
645
  );
613
646
  const b64 = Buffer.from(json).toString('base64');
614
- return `export default JSON.parse(atob("${b64}"));`;
647
+ // atob yields Latin1 bytes; decode through UTF-8 or non-ASCII titles ship as mojibake.
648
+ return `export default JSON.parse(new TextDecoder().decode(Uint8Array.from(atob("${b64}"),(c)=>c.charCodeAt(0))));`;
615
649
  }
616
650
  return null;
617
651
  },
@@ -620,7 +654,56 @@ function tesseraManifestPlugin(manifestRef: {
620
654
 
621
655
  const VIRTUAL_ADAPTER_ID = 'virtual:tessera-adapter';
622
656
 
623
- function tesseraAdapterPlugin(): Plugin {
657
+ // `takesApi`: SCORM detectors return the API object the constructor needs;
658
+ // cmi5/xAPI ones return a boolean.
659
+ const LMS_ADAPTER_GEN: Record<
660
+ 'scorm12' | 'scorm2004' | 'cmi5' | 'xapi',
661
+ { adapter: string; module: string; detect: string; takesApi: boolean }
662
+ > = {
663
+ scorm12: {
664
+ adapter: 'SCORM12Adapter',
665
+ module: 'scorm12',
666
+ detect: 'findSCORM12API',
667
+ takesApi: true,
668
+ },
669
+ scorm2004: {
670
+ adapter: 'SCORM2004Adapter',
671
+ module: 'scorm2004',
672
+ detect: 'findSCORM2004API',
673
+ takesApi: true,
674
+ },
675
+ cmi5: {
676
+ adapter: 'CMI5Adapter',
677
+ module: 'cmi5',
678
+ detect: 'hasCMI5LaunchParams',
679
+ takesApi: false,
680
+ },
681
+ xapi: {
682
+ adapter: 'XAPIAdapter',
683
+ module: 'xapi',
684
+ detect: 'hasXAPILaunchParams',
685
+ takesApi: false,
686
+ },
687
+ };
688
+
689
+ function generateLmsAdapterModule(
690
+ standard: keyof typeof LMS_ADAPTER_GEN,
691
+ ): string {
692
+ const { adapter, module, detect, takesApi } = LMS_ADAPTER_GEN[standard];
693
+ const guard = takesApi
694
+ ? `const api = ${detect}();\n if (!api) throw missingApiError('${standard}');\n return new ${adapter}(api);`
695
+ : `if (!${detect}()) throw missingApiError('${standard}');\n return new ${adapter}();`;
696
+ return `
697
+ import { ${adapter} } from 'tessera-learn/runtime/adapters/${module}.js';
698
+ import { ${detect} } from 'tessera-learn/runtime/adapters/discovery.js';
699
+ import { missingApiError } from 'tessera-learn/runtime/adapters/lms-error.js';
700
+ export function createAdapter() {
701
+ ${guard}
702
+ }
703
+ `;
704
+ }
705
+
706
+ function tesseraAdapterPlugin(standardOverride?: string): Plugin {
624
707
  return virtualModule(
625
708
  'tessera:adapter',
626
709
  VIRTUAL_ADAPTER_ID,
@@ -631,64 +714,30 @@ function tesseraAdapterPlugin(): Plugin {
631
714
  return `export { createAdapter } from 'tessera-learn/runtime/adapters/index.js';`;
632
715
  }
633
716
 
634
- let standard = 'web';
635
- const read = readCourseConfig(projectRoot);
636
- if (read.ok && typeof read.config.export?.standard === 'string') {
637
- standard = read.config.export.standard;
638
- }
717
+ let standard = readResolvedConfig(projectRoot, standardOverride).standard;
639
718
 
640
719
  // The audit renders headless with no LMS in the frame chain; the SCORM/
641
720
  // cmi5 adapters throw when their API is absent, so render with WebAdapter.
642
721
  if (isAuditBuild()) standard = 'web';
643
722
 
644
- switch (standard) {
645
- case 'scorm12':
646
- return `
647
- import { SCORM12Adapter } from 'tessera-learn/runtime/adapters/scorm12.js';
648
- import { findSCORM12API } from 'tessera-learn/runtime/adapters/discovery.js';
649
- import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
650
- export function createAdapter() {
651
- const api = findSCORM12API();
652
- if (!api) throw new LMSAdapterError('scorm12', 'Tessera: SCORM 1.2 API not found in window.parent/opener chain. Course must be launched from a SCORM 1.2 LMS.');
653
- return new SCORM12Adapter(api);
654
- }
655
- `;
656
- case 'scorm2004':
657
- return `
658
- import { SCORM2004Adapter } from 'tessera-learn/runtime/adapters/scorm2004.js';
659
- import { findSCORM2004API } from 'tessera-learn/runtime/adapters/discovery.js';
660
- import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
661
- export function createAdapter() {
662
- const api = findSCORM2004API();
663
- if (!api) throw new LMSAdapterError('scorm2004', 'Tessera: SCORM 2004 API not found in window.parent/opener chain. Course must be launched from a SCORM 2004 LMS.');
664
- return new SCORM2004Adapter(api);
665
- }
666
- `;
667
- case 'cmi5':
668
- return `
669
- import { CMI5Adapter } from 'tessera-learn/runtime/adapters/cmi5.js';
670
- import { hasCMI5LaunchParams } from 'tessera-learn/runtime/adapters/discovery.js';
671
- import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
672
- export function createAdapter() {
673
- if (!hasCMI5LaunchParams()) throw new LMSAdapterError('cmi5', 'Tessera: cmi5 launch parameters not present on URL. Course must be launched from a cmi5-compliant LMS.');
674
- return new CMI5Adapter();
675
- }
676
- `;
677
- default:
678
- return `
723
+ if (standard in LMS_ADAPTER_GEN) {
724
+ return generateLmsAdapterModule(
725
+ standard as keyof typeof LMS_ADAPTER_GEN,
726
+ );
727
+ }
728
+ return `
679
729
  import { WebAdapter } from 'tessera-learn/runtime/adapters/web.js';
680
- export function createAdapter(config) {
681
- return new WebAdapter(config);
730
+ export function createAdapter(config, options) {
731
+ return new WebAdapter(config, options && options.manifest);
682
732
  }
683
733
  `;
684
- }
685
734
  },
686
735
  );
687
736
  }
688
737
 
689
738
  const VIRTUAL_XAPI_SETUP_ID = 'virtual:tessera-xapi-setup';
690
739
 
691
- function tesseraXAPISetupPlugin(): Plugin {
740
+ function tesseraXAPISetupPlugin(standardOverride?: string): Plugin {
692
741
  return virtualModule(
693
742
  'tessera:xapi-setup',
694
743
  VIRTUAL_XAPI_SETUP_ID,
@@ -702,18 +751,14 @@ function tesseraXAPISetupPlugin(): Plugin {
702
751
  return `export async function buildXAPIClient() { return null; }`;
703
752
  }
704
753
 
705
- let standard = 'web';
706
- let hasXapi = false;
707
- const read = readCourseConfig(projectRoot);
708
- if (read.ok) {
709
- if (typeof read.config.export?.standard === 'string')
710
- standard = read.config.export.standard;
711
- hasXapi = read.config.xapi != null;
712
- }
754
+ const read = readResolvedConfig(projectRoot, standardOverride);
755
+ const standard = read.standard;
756
+ const hasXapi = read.ok && read.config.xapi != null;
713
757
 
714
- // cmi5 needs the publisher regardless of explicit xapi config (cmi5
715
- // adapter shares the publisher queue for its own LMS-required statements).
716
- if (hasXapi || standard === 'cmi5') {
758
+ // The launch standards (cmi5, plain xAPI) own a publisher the runtime
759
+ // can share for `endpoint: 'lms'`, so wire the client regardless of
760
+ // explicit xapi config.
761
+ if (hasXapi || standard === 'cmi5' || standard === 'xapi') {
717
762
  return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;
718
763
  }
719
764
 
@@ -15,11 +15,12 @@ import { tesseraPlugin } from './index.js';
15
15
  export function buildInlineConfig(
16
16
  projectRoot: string,
17
17
  workspaceRoot: string,
18
+ standardOverride?: string,
18
19
  ): InlineConfig {
19
20
  return {
20
21
  root: projectRoot,
21
22
  configFile: false,
22
- plugins: [tesseraPlugin()],
23
+ plugins: [tesseraPlugin({ standardOverride })],
23
24
  resolve: { alias: { $shared: resolve(workspaceRoot, 'shared') } },
24
25
  server: { fs: { allow: [workspaceRoot] } },
25
26
  };
@@ -46,9 +47,10 @@ export async function resolveTesseraConfig(
46
47
  projectRoot: string,
47
48
  workspaceRoot: string,
48
49
  env: ConfigEnv,
50
+ standardOverride?: string,
49
51
  ): Promise<InlineConfig> {
50
52
  const vite = await import('vite');
51
- const base = buildInlineConfig(projectRoot, workspaceRoot);
53
+ const base = buildInlineConfig(projectRoot, workspaceRoot, standardOverride);
52
54
  const user = await loadUserConfig(projectRoot, env);
53
55
  return user ? vite.mergeConfig(base, user) : base;
54
56
  }
@@ -90,23 +90,7 @@ export function deriveSlug(name: string, isFile = false): string {
90
90
  return stripPrefix(name);
91
91
  }
92
92
 
93
- export type DefaultExportLiteralResult =
94
- | { kind: 'literal'; text: string }
95
- | { kind: 'none' }
96
- | { kind: 'invalid' }
97
- | { kind: 'parse-error' };
98
-
99
- /**
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.
104
- */
105
- export function extractDefaultExportObjectLiteral(
106
- source: string,
107
- ): DefaultExportLiteralResult {
108
- return defaultExportObjectLiteral(source);
109
- }
93
+ export { defaultExportObjectLiteral as extractDefaultExportObjectLiteral } from './ast.js';
110
94
 
111
95
  export type CourseConfigRead =
112
96
  | { ok: true; config: Partial<CourseConfig> }
@@ -126,9 +110,7 @@ export type CourseConfigRead =
126
110
  export function readCourseConfig(projectRoot: string): CourseConfigRead {
127
111
  const configPath = resolve(projectRoot, 'course.config.js');
128
112
  if (!existsSync(configPath)) return { ok: false, reason: 'missing' };
129
- const result = extractDefaultExportObjectLiteral(
130
- readSourceFileCached(configPath),
131
- );
113
+ const result = defaultExportObjectLiteral(readSourceFileCached(configPath));
132
114
  if (result.kind === 'parse-error')
133
115
  return { ok: false, reason: 'parse-error' };
134
116
  if (result.kind !== 'literal') return { ok: false, reason: 'no-export' };
@@ -139,6 +121,27 @@ export function readCourseConfig(projectRoot: string): CourseConfigRead {
139
121
  }
140
122
  }
141
123
 
124
+ /**
125
+ * Resolve a project's effective export standard once: the CLI `--standard`
126
+ * override wins, else `export.standard`, else `'web'`. An unreadable config with
127
+ * no override fails closed with `'unknown'` so callers withhold standard-specific
128
+ * output rather than guess. The returned `config` already has the override
129
+ * applied, so consumers read it back directly. Exported for tests.
130
+ */
131
+ export function readResolvedConfig(
132
+ projectRoot: string,
133
+ standardOverride?: string,
134
+ ): CourseConfigRead & { standard: string } {
135
+ const read = readCourseConfig(projectRoot);
136
+ if (!read.ok) return { ...read, standard: standardOverride ?? 'unknown' };
137
+ // The CLI validates --standard against the allowed set before it reaches here.
138
+ const override = standardOverride as CourseConfig['export']['standard'];
139
+ const config: Partial<CourseConfig> = override
140
+ ? { ...read.config, export: { ...read.config.export, standard: override } }
141
+ : read.config;
142
+ return { ok: true, config, standard: config.export?.standard || 'web' };
143
+ }
144
+
142
145
  /**
143
146
  * Read a _meta.js file and extract its default export object.
144
147
  * Uses the same JSON5 approach as pageConfig extraction — find the object literal
@@ -150,9 +153,7 @@ export function readMetaFile(metaPath: string): {
150
153
  } {
151
154
  if (!existsSync(metaPath)) return {};
152
155
 
153
- const result = extractDefaultExportObjectLiteral(
154
- readSourceFileCached(metaPath),
155
- );
156
+ const result = defaultExportObjectLiteral(readSourceFileCached(metaPath));
156
157
  if (result.kind !== 'literal') return {};
157
158
 
158
159
  try {
@@ -1,4 +1,5 @@
1
1
  import { existsSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { join, relative, resolve } from 'node:path';
3
4
  import { findWorkspaceRoot } from './course-root.js';
4
5
  import { validateProjectName, toTitleCase } from './project-name.js';
@@ -43,6 +44,7 @@ export function runNew(name: string | undefined, cwd: string): number {
43
44
  const templateDir = join(resolvePackageRoot(), 'templates', 'course');
44
45
  copyTemplate(templateDir, courseDir, {
45
46
  PROJECT_TITLE: toTitleCase(name),
47
+ COURSE_ID: `urn:uuid:${randomUUID()}`,
46
48
  });
47
49
 
48
50
  const rel = relative(workspaceRoot, courseDir);
@@ -3,9 +3,12 @@ import { validateProject, reportValidationIssues } from './validation.js';
3
3
 
4
4
  export function runValidate(
5
5
  projectRoot: string,
6
- { showA11yTip = true }: { showA11yTip?: boolean } = {},
6
+ {
7
+ showA11yTip = true,
8
+ standardOverride,
9
+ }: { showA11yTip?: boolean; standardOverride?: string } = {},
7
10
  ): number {
8
- const { errors, warnings } = validateProject(projectRoot);
11
+ const { errors, warnings } = validateProject(projectRoot, standardOverride);
9
12
 
10
13
  reportValidationIssues({ errors, warnings });
11
14