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.
- package/AGENTS.md +93 -75
- package/README.md +11 -0
- package/dist/plugin/index.js +79 -78
- package/dist/plugin/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/FillInTheBlank.svelte +19 -69
- package/src/components/LockedBanner.svelte +30 -0
- package/src/components/Matching.svelte +44 -80
- package/src/components/MultipleChoice.svelte +14 -43
- package/src/components/Quiz.svelte +69 -263
- package/src/components/ResultIcon.svelte +13 -0
- package/src/components/RetryButton.svelte +25 -0
- package/src/components/Sorting.svelte +33 -76
- package/src/components/util.ts +10 -0
- package/src/plugin/export.ts +39 -33
- package/src/plugin/manifest.ts +38 -12
- package/src/plugin/validation.ts +36 -69
- package/src/runtime/App.svelte +15 -20
- package/src/runtime/ErrorPage.svelte +1 -1
- package/src/runtime/adapters/retry.ts +48 -41
- package/src/runtime/adapters/scorm-base.ts +143 -0
- package/src/runtime/adapters/scorm12.ts +37 -117
- package/src/runtime/adapters/scorm2004.ts +34 -115
- package/src/runtime/hooks.svelte.ts +63 -29
- package/src/runtime/xapi/client.ts +2 -2
- package/src/runtime/xapi/publisher.ts +15 -6
- package/src/runtime/xapi/setup.ts +8 -15
- package/styles/layout.css +21 -10
- package/styles/theme.css +4 -0
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
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="
|
|
229
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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 &&
|
|
296
|
-
<
|
|
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
|
-
<
|
|
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:
|
|
389
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
583
|
+
background: var(--tessera-success-bg);
|
|
596
584
|
}
|
|
597
585
|
|
|
598
586
|
.tessera-sorting-feedback.incorrect {
|
|
599
587
|
color: var(--tessera-error);
|
|
600
|
-
background:
|
|
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 --- */
|
package/src/components/util.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/plugin/export.ts
CHANGED
|
@@ -70,10 +70,36 @@ function formatSize(bytes: number): string {
|
|
|
70
70
|
|
|
71
71
|
// ---------- Manifest Generators ----------
|
|
72
72
|
|
|
73
|
-
|
|
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="
|
|
86
|
-
xmlns:adlcp="
|
|
111
|
+
xmlns="${dialect.rootNs}"
|
|
112
|
+
xmlns:adlcp="${dialect.adlcpNs}">
|
|
87
113
|
<metadata>
|
|
88
114
|
<schema>ADL SCORM</schema>
|
|
89
|
-
<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
|
|
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
|
|
133
|
+
export function generateSCORM12Manifest(
|
|
108
134
|
config: ExportConfig,
|
|
109
135
|
distDir: string
|
|
110
136
|
): string {
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 {
|
package/src/plugin/manifest.ts
CHANGED
|
@@ -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
|
-
|
|
122
|
-
*/
|
|
123
|
-
|
|
124
|
-
|
|
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(
|
|
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
|
|
package/src/plugin/validation.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
700
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
);
|