tessera-learn 0.0.1 → 0.0.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.
@@ -1,7 +1,11 @@
1
1
  <script>
2
2
  import { getContext, onMount } from 'svelte';
3
+ import { SvelteMap } from 'svelte/reactivity';
3
4
  import { useQuestion } from '../runtime/hooks.svelte.js';
4
- import { slugFromQuestion } from './util.js';
5
+ import { slugFromQuestion, shuffle } from './util.js';
6
+ import LockedBanner from './LockedBanner.svelte';
7
+ import ResultIcon from './ResultIcon.svelte';
8
+ import RetryButton from './RetryButton.svelte';
5
9
 
6
10
  let {
7
11
  id,
@@ -19,28 +23,14 @@
19
23
  const standalone = !quiz;
20
24
 
21
25
  let queue = $state([]); // item indices not yet placed; queue[0] is current
22
- let placements = $state(new Map()); // itemIdx → targetIdx
26
+ let placements = $state(new SvelteMap()); // itemIdx → targetIdx
23
27
  let dragOver = $state(null); // target index highlighted during drag
24
28
  let isDragging = $state(false);
25
29
  let cardSelected = $state(false); // current card selected via tap/click
26
30
 
27
- let saRetryCount = $state(0);
28
- let saCanRetry = $derived(saRetryCount < maxRetries);
29
-
30
- const defaultId = `sorting-${slugFromQuestion(question)}`;
31
-
32
- function shuffle(arr) {
33
- const a = [...arr];
34
- for (let i = a.length - 1; i > 0; i--) {
35
- const j = Math.floor(Math.random() * (i + 1));
36
- [a[i], a[j]] = [a[j], a[i]];
37
- }
38
- return a;
39
- }
40
-
41
31
  function initQueue() {
42
32
  queue = shuffle(items.map((_, i) => i));
43
- placements = new Map();
33
+ placements = new SvelteMap();
44
34
  cardSelected = false;
45
35
  dragOver = null;
46
36
  isDragging = false;
@@ -72,8 +62,9 @@
72
62
  // cleanly to SCORM 2004's `matching` interaction. We emit [itemIdx, targetIdx]
73
63
  // pairs as stringified ids.
74
64
  const handle = useQuestion({
75
- id: id ?? defaultId,
76
- weight,
65
+ get id() { return id ?? `sorting-${slugFromQuestion(question)}`; },
66
+ get weight() { return weight; },
67
+ get maxRetries() { return maxRetries; },
77
68
  response: () => ({
78
69
  type: 'matching',
79
70
  response: [...placements.entries()].map(([i, t]) => [String(i), String(t)]),
@@ -170,10 +161,13 @@
170
161
  placeCard(targetIdx);
171
162
  }
172
163
 
173
- function handleRetry() {
174
- saRetryCount++;
175
- handle.reset();
164
+ function onTargetKeydown(e, targetIdx) {
165
+ if (e.key === 'Enter' || e.key === ' ') {
166
+ e.preventDefault();
167
+ onTargetClick(targetIdx);
168
+ }
176
169
  }
170
+
177
171
  </script>
178
172
 
179
173
  {#snippet sortingContent()}
@@ -225,12 +219,15 @@
225
219
  class="tessera-sorting-target"
226
220
  class:drag-over={dragOver === targetIdx}
227
221
  class:clickable={cardSelected && !isDisabled}
228
- role="group"
229
- aria-label="Target: {targetLabel}"
222
+ role="button"
223
+ tabindex="0"
224
+ aria-disabled={!(cardSelected && !isDisabled)}
225
+ aria-label="Target: {targetLabel}{cardSelected && !isDisabled ? ` (activate to place ${items[currentItemIdx]})` : ''}"
230
226
  ondragover={(e) => onDragOver(e, targetIdx)}
231
227
  ondragleave={onDragLeave}
232
228
  ondrop={(e) => onDrop(e, targetIdx)}
233
229
  onclick={() => onTargetClick(targetIdx)}
230
+ onkeydown={(e) => onTargetKeydown(e, targetIdx)}
234
231
  >
235
232
  <div class="tessera-sorting-target-label">{targetLabel}</div>
236
233
  {#if targetItems.length > 0}
@@ -267,9 +264,7 @@
267
264
  <div class="tessera-sorting-review">
268
265
  {#if isCorrect}
269
266
  <div class="tessera-sorting-result correct">
270
- <svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16" aria-hidden="true">
271
- <path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
272
- </svg>
267
+ <ResultIcon kind="correct" />
273
268
  All items sorted correctly!
274
269
  </div>
275
270
  {#if correctFeedback}
@@ -277,9 +272,7 @@
277
272
  {/if}
278
273
  {:else}
279
274
  <div class="tessera-sorting-result incorrect">
280
- <svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16" aria-hidden="true">
281
- <path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"/>
282
- </svg>
275
+ <ResultIcon kind="incorrect" />
283
276
  Some items are in the wrong category.
284
277
  </div>
285
278
  <div class="tessera-sorting-correct-list">
@@ -292,8 +285,8 @@
292
285
  <p class="tessera-sorting-feedback incorrect">{incorrectFeedback}</p>
293
286
  {/if}
294
287
  {/if}
295
- {#if standalone && saCanRetry}
296
- <button class="tessera-standalone-retry" onclick={handleRetry}>Try again</button>
288
+ {#if standalone && handle.canRetry}
289
+ <RetryButton onclick={() => handle.retry()} />
297
290
  {/if}
298
291
  </div>
299
292
  {/if}
@@ -301,7 +294,7 @@
301
294
  <!-- Standalone Check button (shown once all cards are placed) -->
302
295
  {#if standalone && !handle.submitted && placements.size === items.length}
303
296
  <div class="tessera-sorting-actions">
304
- <button class="tessera-sorting-check" onclick={() => handle.submit()}>
297
+ <button class="tessera-btn-primary tessera-sorting-check" onclick={() => handle.submit()}>
305
298
  Check Answer
306
299
  </button>
307
300
  </div>
@@ -317,12 +310,7 @@
317
310
  {#snippet renderQuestion()}
318
311
  <div class="tessera-sorting" aria-label={question}>
319
312
  {#if isLocked}
320
- <div class="tessera-quiz-locked-banner">
321
- <svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16" aria-hidden="true">
322
- <path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
323
- </svg>
324
- You already got this one right — click Next to continue.
325
- </div>
313
+ <LockedBanner />
326
314
  {/if}
327
315
  {@render sortingContent()}
328
316
  </div>
@@ -385,8 +373,8 @@
385
373
  }
386
374
 
387
375
  .tessera-sorting-card:focus-visible {
388
- outline: var(--tessera-focus-ring, 0 0 0 3px rgba(37, 99, 235, 0.4));
389
- outline-offset: 2px;
376
+ outline: none;
377
+ box-shadow: var(--tessera-focus-ring);
390
378
  border-color: var(--tessera-primary);
391
379
  }
392
380
 
@@ -498,12 +486,12 @@
498
486
 
499
487
  .tessera-sorting-placed-item.correct {
500
488
  border-color: var(--tessera-success);
501
- background: color-mix(in srgb, var(--tessera-success) 8%, transparent);
489
+ background: var(--tessera-success-bg);
502
490
  }
503
491
 
504
492
  .tessera-sorting-placed-item.incorrect {
505
493
  border-color: var(--tessera-error);
506
- background: color-mix(in srgb, var(--tessera-error) 8%, transparent);
494
+ background: var(--tessera-error-bg);
507
495
  }
508
496
 
509
497
  .tessera-sorting-item-text {
@@ -592,12 +580,12 @@
592
580
 
593
581
  .tessera-sorting-feedback.correct {
594
582
  color: var(--tessera-success);
595
- background: color-mix(in srgb, var(--tessera-success) 8%, transparent);
583
+ background: var(--tessera-success-bg);
596
584
  }
597
585
 
598
586
  .tessera-sorting-feedback.incorrect {
599
587
  color: var(--tessera-error);
600
- background: color-mix(in srgb, var(--tessera-error) 8%, transparent);
588
+ background: var(--tessera-error-bg);
601
589
  }
602
590
 
603
591
  /* --- Standalone actions --- */
@@ -608,39 +596,8 @@
608
596
 
609
597
  .tessera-sorting-check {
610
598
  padding: 0.625rem 1.5rem;
611
- background: var(--tessera-primary);
612
- color: #fff;
613
- border: none;
614
- border-radius: 6px;
615
599
  font-size: 0.9375rem;
616
600
  font-weight: 500;
617
- font-family: var(--tessera-font-family);
618
- cursor: pointer;
619
- min-height: 44px;
620
- transition: background 0.2s;
621
- }
622
-
623
- .tessera-sorting-check:hover {
624
- background: var(--tessera-primary-dark);
625
- }
626
-
627
- .tessera-standalone-retry {
628
- display: inline-block;
629
- margin-top: var(--tessera-spacing-md);
630
- padding: 0;
631
- font-size: 0.875rem;
632
- font-weight: 600;
633
- color: var(--tessera-primary);
634
- background: none;
635
- border: none;
636
- cursor: pointer;
637
- text-decoration: underline;
638
- text-underline-offset: 2px;
639
- font-family: var(--tessera-font-family);
640
- }
641
-
642
- .tessera-standalone-retry:hover {
643
- color: var(--tessera-primary-dark);
644
601
  }
645
602
 
646
603
  /* --- Mobile --- */
@@ -22,3 +22,13 @@ export function slugFromQuestion(text: unknown): string {
22
22
  .replace(/^-|-$/g, '')
23
23
  .slice(0, 40);
24
24
  }
25
+
26
+ /** Fisher-Yates shuffle returning a fresh array. */
27
+ export function shuffle<T>(arr: readonly T[]): T[] {
28
+ const result = arr.slice();
29
+ for (let i = result.length - 1; i > 0; i--) {
30
+ const j = Math.floor(Math.random() * (i + 1));
31
+ [result[i], result[j]] = [result[j], result[i]];
32
+ }
33
+ return result;
34
+ }
@@ -70,10 +70,36 @@ function formatSize(bytes: number): string {
70
70
 
71
71
  // ---------- Manifest Generators ----------
72
72
 
73
- export function generateSCORM12Manifest(
73
+ /** Per-version XML differences in imsmanifest.xml between SCORM 1.2 and 2004. */
74
+ interface ScormManifestDialect {
75
+ rootNs: string;
76
+ adlcpNs: string;
77
+ schemaversion: string;
78
+ /** Attribute name on <resource>: SCORM 1.2 uses lowercase, 2004 uses camelCase. */
79
+ scormTypeAttr: 'scormtype' | 'scormType';
80
+ }
81
+
82
+ const SCORM_DIALECTS: Record<'1.2' | '2004', ScormManifestDialect> = {
83
+ '1.2': {
84
+ rootNs: 'http://www.imsproject.org/xsd/imscp_rootv1p1p2',
85
+ adlcpNs: 'http://www.adlnet.org/xsd/adlcp_rootv1p2',
86
+ schemaversion: '1.2',
87
+ scormTypeAttr: 'scormtype',
88
+ },
89
+ '2004': {
90
+ rootNs: 'http://www.imsglobal.org/xsd/imscp_v1p1',
91
+ adlcpNs: 'http://www.adlnet.org/xsd/adlcp_v1p3',
92
+ schemaversion: '2004 4th Edition',
93
+ scormTypeAttr: 'scormType',
94
+ },
95
+ };
96
+
97
+ export function generateScormManifest(
98
+ version: '1.2' | '2004',
74
99
  config: ExportConfig,
75
100
  distDir: string
76
101
  ): string {
102
+ const dialect = SCORM_DIALECTS[version];
77
103
  const title = escapeXml(config.title || 'Tessera Course');
78
104
  const files = collectFiles(distDir);
79
105
  const fileElements = files
@@ -82,11 +108,11 @@ export function generateSCORM12Manifest(
82
108
 
83
109
  return `<?xml version="1.0" encoding="UTF-8"?>
84
110
  <manifest identifier="tessera-course" version="1.0"
85
- xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
86
- xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2">
111
+ xmlns="${dialect.rootNs}"
112
+ xmlns:adlcp="${dialect.adlcpNs}">
87
113
  <metadata>
88
114
  <schema>ADL SCORM</schema>
89
- <schemaversion>1.2</schemaversion>
115
+ <schemaversion>${dialect.schemaversion}</schemaversion>
90
116
  </metadata>
91
117
  <organizations default="org-1">
92
118
  <organization identifier="org-1">
@@ -97,45 +123,25 @@ export function generateSCORM12Manifest(
97
123
  </organization>
98
124
  </organizations>
99
125
  <resources>
100
- <resource identifier="res-1" type="webcontent" adlcp:scormtype="sco" href="index.html">
126
+ <resource identifier="res-1" type="webcontent" adlcp:${dialect.scormTypeAttr}="sco" href="index.html">
101
127
  ${fileElements}
102
128
  </resource>
103
129
  </resources>
104
130
  </manifest>`;
105
131
  }
106
132
 
107
- export function generateSCORM2004Manifest(
133
+ export function generateSCORM12Manifest(
108
134
  config: ExportConfig,
109
135
  distDir: string
110
136
  ): string {
111
- const title = escapeXml(config.title || 'Tessera Course');
112
- const files = collectFiles(distDir);
113
- const fileElements = files
114
- .map((f) => ` <file href="${escapeXml(f)}" />`)
115
- .join('\n');
137
+ return generateScormManifest('1.2', config, distDir);
138
+ }
116
139
 
117
- return `<?xml version="1.0" encoding="UTF-8"?>
118
- <manifest identifier="tessera-course" version="1.0"
119
- xmlns="http://www.imsglobal.org/xsd/imscp_v1p1"
120
- xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_v1p3">
121
- <metadata>
122
- <schema>ADL SCORM</schema>
123
- <schemaversion>2004 4th Edition</schemaversion>
124
- </metadata>
125
- <organizations default="org-1">
126
- <organization identifier="org-1">
127
- <title>${title}</title>
128
- <item identifier="item-1" identifierref="res-1">
129
- <title>${title}</title>
130
- </item>
131
- </organization>
132
- </organizations>
133
- <resources>
134
- <resource identifier="res-1" type="webcontent" adlcp:scormType="sco" href="index.html">
135
- ${fileElements}
136
- </resource>
137
- </resources>
138
- </manifest>`;
140
+ export function generateSCORM2004Manifest(
141
+ config: ExportConfig,
142
+ distDir: string
143
+ ): string {
144
+ return generateScormManifest('2004', config, distDir);
139
145
  }
140
146
 
141
147
  export function generateCMI5Xml(config: ExportConfig): string {
@@ -33,6 +33,11 @@ export interface Manifest {
33
33
  totalPages: number;
34
34
  }
35
35
 
36
+ /** Append `.svelte` if not already present. Both bare and suffixed names are accepted in author config. */
37
+ export function ensureSvelteSuffix(name: string): string {
38
+ return name.endsWith('.svelte') ? name : `${name}.svelte`;
39
+ }
40
+
36
41
  // ---------- File read cache ----------
37
42
 
38
43
  /**
@@ -117,32 +122,53 @@ export function readMetaFile(metaPath: string): { title?: string; pages?: string
117
122
  }
118
123
  }
119
124
 
120
- /**
121
- * Extract pageConfig from a .svelte file's module script block.
122
- */
123
- export function extractPageConfig(filePath: string): { title?: string; quiz?: QuizConfig } {
124
- const content = readSourceFileCached(filePath);
125
-
125
+ /** Result of parsing a `.svelte` source for its `pageConfig` module-script export. */
126
+ export type PageConfigParseResult =
127
+ /** No module script, or no `pageConfig =` export. Treat as "no config". */
128
+ | { kind: 'none' }
129
+ /** Found and successfully parsed. */
130
+ | { kind: 'ok'; value: { title?: string; quiz?: QuizConfig } }
131
+ /** Found but couldn't parse as a static object literal — non-literal RHS or JSON5 failure. */
132
+ | { kind: 'invalid' };
133
+
134
+ /** Source-level pageConfig extraction shared by manifest generation and build-time validation. */
135
+ export function parsePageConfigFromSource(content: string): PageConfigParseResult {
126
136
  const moduleScriptMatch = content.match(MODULE_SCRIPT_RE);
127
- if (!moduleScriptMatch) return {};
137
+ if (!moduleScriptMatch) return { kind: 'none' };
128
138
 
129
139
  const scriptContent = moduleScriptMatch[1];
130
140
 
131
141
  const configMatch = scriptContent.match(PAGE_CONFIG_EXPORT_RE);
132
- if (!configMatch || configMatch.index === undefined) return {};
142
+ if (!configMatch || configMatch.index === undefined) return { kind: 'none' };
143
+
144
+ const afterExport = scriptContent
145
+ .slice(configMatch.index + configMatch[0].length)
146
+ .trimStart();
147
+ // pageConfig assigned to something other than an object literal — flag as invalid.
148
+ if (!afterExport.startsWith('{')) return { kind: 'invalid' };
133
149
 
134
150
  const startIndex = scriptContent.indexOf('{', configMatch.index + configMatch[0].length);
135
- if (startIndex < 0) return {};
151
+ if (startIndex < 0) return { kind: 'invalid' };
136
152
  const objectStr = extractObjectLiteral(scriptContent, startIndex);
137
- if (!objectStr) return {};
153
+ if (!objectStr) return { kind: 'invalid' };
138
154
 
139
155
  try {
140
- return JSON5.parse(objectStr);
156
+ return { kind: 'ok', value: JSON5.parse(objectStr) };
141
157
  } catch {
158
+ return { kind: 'invalid' };
159
+ }
160
+ }
161
+
162
+ /** Extract pageConfig from a .svelte file. Throws on parse failure. */
163
+ export function extractPageConfig(filePath: string): { title?: string; quiz?: QuizConfig } {
164
+ const result = parsePageConfigFromSource(readSourceFileCached(filePath));
165
+ if (result.kind === 'ok') return result.value;
166
+ if (result.kind === 'invalid') {
142
167
  throw new Error(
143
168
  `${filePath}: pageConfig must be a static object literal (no variables, function calls, or computed values)`
144
169
  );
145
170
  }
171
+ return {};
146
172
  }
147
173
 
148
174
  /**
@@ -319,7 +345,7 @@ export function orderPageFiles(allFiles: string[], pagesArray?: string[]): strin
319
345
  return allFiles;
320
346
  }
321
347
 
322
- const listed = pagesArray.map(name => name.endsWith('.svelte') ? name : `${name}.svelte`);
348
+ const listed = pagesArray.map(ensureSvelteSuffix);
323
349
  const listedSet = new Set(listed);
324
350
  const unlisted = allFiles.filter(f => !listedSet.has(f)).sort();
325
351
 
@@ -2,11 +2,10 @@ import { existsSync, readdirSync, statSync } from 'node:fs';
2
2
  import { resolve, relative } from 'node:path';
3
3
  import JSON5 from 'json5';
4
4
  import {
5
- extractObjectLiteral,
6
5
  extractDefaultExportObjectLiteral,
6
+ parsePageConfigFromSource,
7
7
  readSourceFileCached,
8
- MODULE_SCRIPT_RE,
9
- PAGE_CONFIG_EXPORT_RE,
8
+ ensureSvelteSuffix,
10
9
  } from './manifest.js';
11
10
  import { validateAgent } from '../runtime/xapi/agent-rules.js';
12
11
 
@@ -371,7 +370,7 @@ function validateSingleXAPIEntry(
371
370
  );
372
371
  }
373
372
  } else if (typeof actor === 'object' && actor !== null) {
374
- const err = validateStaticAgent(actor);
373
+ const err = validateAgent(actor);
375
374
  if (err) {
376
375
  const joined = err.startsWith('.')
377
376
  ? `${label}.actor${err}`
@@ -451,13 +450,6 @@ function validateSingleXAPIEntry(
451
450
  }
452
451
  }
453
452
 
454
- /**
455
- * Build-time alias for the shared `validateAgent` rules. Suffixes are already
456
- * prefix-friendly (no leading "actor"), so this is a straight pass-through —
457
- * kept named so the call sites in this file stay readable.
458
- */
459
- const validateStaticAgent = validateAgent;
460
-
461
453
  // ---------- Pages Validation ----------
462
454
 
463
455
  interface PagesValidationResult extends ValidationResult {
@@ -476,6 +468,8 @@ function validatePages(
476
468
  let totalPages = 0;
477
469
  let totalQuizzes = 0;
478
470
  let hasGradedQuiz = false;
471
+ // One existsSync per unique asset for the whole pass.
472
+ const assetExistsCache = new Map<string, boolean>();
479
473
 
480
474
  if (!existsSync(pagesDir)) {
481
475
  errors.push(
@@ -535,9 +529,7 @@ function validatePages(
535
529
 
536
530
  if (sectionMeta?.pages) {
537
531
  for (const pageName of sectionMeta.pages) {
538
- const fileName = pageName.endsWith('.svelte')
539
- ? pageName
540
- : `${pageName}.svelte`;
532
+ const fileName = ensureSvelteSuffix(pageName);
541
533
  if (!sectionSvelteFiles.includes(fileName)) {
542
534
  const metaRel = relative(projectRoot, resolve(sectionPath, '_meta.js'));
543
535
  errors.push(
@@ -563,7 +555,7 @@ function validatePages(
563
555
  }
564
556
  }
565
557
 
566
- validateAssetRefs(content, fileRel, assetsDir, warnings);
558
+ validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
567
559
  }
568
560
 
569
561
  // Get lesson directories
@@ -593,9 +585,7 @@ function validatePages(
593
585
  // Check pages array references
594
586
  if (meta?.pages) {
595
587
  for (const pageName of meta.pages) {
596
- const fileName = pageName.endsWith('.svelte')
597
- ? pageName
598
- : `${pageName}.svelte`;
588
+ const fileName = ensureSvelteSuffix(pageName);
599
589
  if (!svelteFiles.includes(fileName)) {
600
590
  const metaRel = relative(projectRoot, resolve(lessonPath, '_meta.js'));
601
591
  errors.push(
@@ -607,11 +597,7 @@ function validatePages(
607
597
 
608
598
  // Check for unlisted .svelte files
609
599
  if (meta?.pages && meta.pages.length > 0) {
610
- const listedSet = new Set(
611
- meta.pages.map((p: string) =>
612
- p.endsWith('.svelte') ? p : `${p}.svelte`
613
- )
614
- );
600
+ const listedSet = new Set(meta.pages.map(ensureSvelteSuffix));
615
601
  for (const file of svelteFiles) {
616
602
  if (!listedSet.has(file)) {
617
603
  const relPath = relative(projectRoot, resolve(lessonPath, file));
@@ -643,7 +629,7 @@ function validatePages(
643
629
  }
644
630
 
645
631
  // Check $assets references
646
- validateAssetRefs(content, fileRel, assetsDir, warnings);
632
+ validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
647
633
  }
648
634
  }
649
635
  }
@@ -696,47 +682,14 @@ function validatePageConfig(
696
682
  fileRel: string,
697
683
  errors: string[]
698
684
  ): { title?: string; quiz?: unknown } | null {
699
- const moduleScriptMatch = content.match(MODULE_SCRIPT_RE);
700
- if (!moduleScriptMatch) return null;
701
-
702
- const scriptContent = moduleScriptMatch[1];
703
- const exportMatch = scriptContent.match(PAGE_CONFIG_EXPORT_RE);
704
- if (!exportMatch || exportMatch.index === undefined) return null;
705
-
706
- // Now check if the RHS starts with `{` (a static object literal)
707
- const afterEquals = scriptContent
708
- .slice(exportMatch.index + exportMatch[0].length)
709
- .trimStart();
710
-
711
- if (!afterEquals.startsWith('{')) {
712
- // pageConfig is exported but assigned to something other than an object literal
713
- errors.push(
714
- `${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`
715
- );
716
- return null;
717
- }
718
-
719
- // Find the opening brace in the original scriptContent
720
- const braceIndex = scriptContent.indexOf(
721
- '{',
722
- exportMatch.index + exportMatch[0].length
723
- );
724
- const objectStr = extractObjectLiteral(scriptContent, braceIndex);
725
- if (!objectStr) {
726
- errors.push(
727
- `${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`
728
- );
729
- return null;
730
- }
731
-
732
- try {
733
- return JSON5.parse(objectStr);
734
- } catch {
685
+ const result = parsePageConfigFromSource(content);
686
+ if (result.kind === 'ok') return result.value;
687
+ if (result.kind === 'invalid') {
735
688
  errors.push(
736
689
  `${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`
737
690
  );
738
- return null;
739
691
  }
692
+ return null;
740
693
  }
741
694
 
742
695
  // ---------- Quiz Config Validation ----------
@@ -763,20 +716,34 @@ function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): v
763
716
 
764
717
  // ---------- Asset Reference Validation ----------
765
718
 
719
+ const ASSET_REF_RE = /\$assets\/([^\s"'`)]+)/g;
720
+
721
+ /** Match $assets/... refs in any context (src attrs, import statements, url() etc) and dedupe. */
722
+ function collectAssetRefs(content: string): string[] {
723
+ const seen = new Set<string>();
724
+ let match: RegExpExecArray | null;
725
+ ASSET_REF_RE.lastIndex = 0;
726
+ while ((match = ASSET_REF_RE.exec(content)) !== null) {
727
+ seen.add(match[1]);
728
+ }
729
+ return [...seen];
730
+ }
731
+
766
732
  function validateAssetRefs(
767
733
  content: string,
768
734
  fileRel: string,
769
735
  assetsDir: string,
770
- warnings: string[]
736
+ warnings: string[],
737
+ existsCache: Map<string, boolean>
771
738
  ): void {
772
- // Match $assets/... references in src attributes, import statements, url() etc.
773
- const assetRefPattern = /\$assets\/([^\s"'`)]+)/g;
774
- let match: RegExpExecArray | null;
775
-
776
- while ((match = assetRefPattern.exec(content)) !== null) {
777
- const assetPath = match[1];
739
+ for (const assetPath of collectAssetRefs(content)) {
778
740
  const fullAssetPath = resolve(assetsDir, assetPath);
779
- if (!existsSync(fullAssetPath)) {
741
+ let exists = existsCache.get(fullAssetPath);
742
+ if (exists === undefined) {
743
+ exists = existsSync(fullAssetPath);
744
+ existsCache.set(fullAssetPath, exists);
745
+ }
746
+ if (!exists) {
780
747
  warnings.push(
781
748
  `${fileRel}: "$assets/${assetPath}" not found in assets/ directory`
782
749
  );