tessera-learn 0.0.1
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 +1228 -0
- package/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/plugin/index.d.ts +7 -0
- package/dist/plugin/index.d.ts.map +1 -0
- package/dist/plugin/index.js +1239 -0
- package/dist/plugin/index.js.map +1 -0
- package/package.json +77 -0
- package/src/archiver.d.ts +27 -0
- package/src/components/Accordion.svelte +32 -0
- package/src/components/AccordionItem.svelte +144 -0
- package/src/components/Audio.svelte +38 -0
- package/src/components/Callout.svelte +81 -0
- package/src/components/Carousel.svelte +194 -0
- package/src/components/CarouselSlide.svelte +32 -0
- package/src/components/DefaultLayout.svelte +108 -0
- package/src/components/FillInTheBlank.svelte +345 -0
- package/src/components/Image.svelte +47 -0
- package/src/components/Matching.svelte +513 -0
- package/src/components/MultipleChoice.svelte +363 -0
- package/src/components/Quiz.svelte +569 -0
- package/src/components/RevealModal.svelte +228 -0
- package/src/components/Sorting.svelte +663 -0
- package/src/components/Video.svelte +118 -0
- package/src/components/index.ts +15 -0
- package/src/components/quiz-payload.ts +71 -0
- package/src/components/util.ts +24 -0
- package/src/index.ts +56 -0
- package/src/plugin/export.ts +264 -0
- package/src/plugin/index.ts +464 -0
- package/src/plugin/layout.ts +55 -0
- package/src/plugin/manifest.ts +330 -0
- package/src/plugin/quiz.ts +65 -0
- package/src/plugin/validation.ts +838 -0
- package/src/runtime/App.svelte +435 -0
- package/src/runtime/ErrorPage.svelte +14 -0
- package/src/runtime/LoadingSkeleton.svelte +26 -0
- package/src/runtime/Sidebar.svelte +76 -0
- package/src/runtime/access.ts +55 -0
- package/src/runtime/adapters/cmi5.ts +341 -0
- package/src/runtime/adapters/discovery.ts +38 -0
- package/src/runtime/adapters/index.ts +99 -0
- package/src/runtime/adapters/retry.ts +284 -0
- package/src/runtime/adapters/scorm12.ts +172 -0
- package/src/runtime/adapters/scorm2004.ts +162 -0
- package/src/runtime/adapters/web.ts +62 -0
- package/src/runtime/contexts.ts +76 -0
- package/src/runtime/duration.ts +29 -0
- package/src/runtime/hooks.svelte.ts +543 -0
- package/src/runtime/interaction-format.ts +132 -0
- package/src/runtime/interaction.ts +96 -0
- package/src/runtime/navigation.svelte.ts +117 -0
- package/src/runtime/persistence.ts +56 -0
- package/src/runtime/progress.svelte.ts +168 -0
- package/src/runtime/quiz-policy.ts +227 -0
- package/src/runtime/slugify.ts +17 -0
- package/src/runtime/types.ts +92 -0
- package/src/runtime/xapi/agent-rules.ts +93 -0
- package/src/runtime/xapi/client.ts +133 -0
- package/src/runtime/xapi/derive-actor.ts +90 -0
- package/src/runtime/xapi/publisher.ts +604 -0
- package/src/runtime/xapi/registry.ts +38 -0
- package/src/runtime/xapi/setup.ts +250 -0
- package/src/runtime/xapi/types.ts +106 -0
- package/src/runtime/xapi/uuid.ts +21 -0
- package/src/runtime/xapi/validation.ts +71 -0
- package/src/runtime/xapi/version.ts +23 -0
- package/src/virtual.d.ts +16 -0
- package/styles/base.css +194 -0
- package/styles/layout.css +408 -0
- package/styles/theme.css +36 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { resolve, basename, extname } from 'node:path';
|
|
3
|
+
import JSON5 from 'json5';
|
|
4
|
+
import type { QuizConfig } from '../runtime/types.js';
|
|
5
|
+
|
|
6
|
+
// ---------- Types ----------
|
|
7
|
+
|
|
8
|
+
export type { QuizConfig };
|
|
9
|
+
|
|
10
|
+
export interface ManifestPage {
|
|
11
|
+
index: number;
|
|
12
|
+
title: string;
|
|
13
|
+
slug: string;
|
|
14
|
+
importPath: string;
|
|
15
|
+
quiz: QuizConfig | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ManifestLesson {
|
|
19
|
+
title: string;
|
|
20
|
+
slug: string;
|
|
21
|
+
pages: ManifestPage[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ManifestSection {
|
|
25
|
+
title: string;
|
|
26
|
+
slug: string;
|
|
27
|
+
lessons: ManifestLesson[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Manifest {
|
|
31
|
+
sections: ManifestSection[];
|
|
32
|
+
pages: ManifestPage[];
|
|
33
|
+
totalPages: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------- File read cache ----------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Module-level cache of source file contents keyed by absolute path with
|
|
40
|
+
* mtime invalidation. Both `validateProject` and `generateManifest` read the
|
|
41
|
+
* same .svelte / _meta.js / course.config.js files during a single build;
|
|
42
|
+
* sharing the read avoids the second disk hit (and matters most on cold-cache
|
|
43
|
+
* CI runs and large courses).
|
|
44
|
+
*/
|
|
45
|
+
const fileContentCache = new Map<string, { mtimeMs: number; content: string }>();
|
|
46
|
+
|
|
47
|
+
export function readSourceFileCached(filePath: string): string {
|
|
48
|
+
const stat = statSync(filePath);
|
|
49
|
+
const cached = fileContentCache.get(filePath);
|
|
50
|
+
if (cached && cached.mtimeMs === stat.mtimeMs) return cached.content;
|
|
51
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
52
|
+
fileContentCache.set(filePath, { mtimeMs: stat.mtimeMs, content });
|
|
53
|
+
return content;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------- Helpers ----------
|
|
57
|
+
|
|
58
|
+
/** Strip numeric prefix and hyphen: "01-introduction" → "introduction" */
|
|
59
|
+
export function stripPrefix(name: string): string {
|
|
60
|
+
return name.replace(/^\d+-/, '');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Title-case a slug: "getting-started" → "Getting Started" */
|
|
64
|
+
export function titleCase(slug: string): string {
|
|
65
|
+
return slug
|
|
66
|
+
.split('-')
|
|
67
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
68
|
+
.join(' ');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Derive slug from folder/file name */
|
|
72
|
+
export function deriveSlug(name: string, isFile = false): string {
|
|
73
|
+
if (isFile) {
|
|
74
|
+
return basename(name, extname(name));
|
|
75
|
+
}
|
|
76
|
+
return stripPrefix(name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Matches both Svelte 5 `<script module>` and legacy `<script context="module">`. */
|
|
80
|
+
export const MODULE_SCRIPT_RE =
|
|
81
|
+
/<script\s+(?:context\s*=\s*["']module["']|module)[^>]*>([\s\S]*?)<\/script>/;
|
|
82
|
+
|
|
83
|
+
/** Matches `export const pageConfig =` (RHS is read separately). */
|
|
84
|
+
export const PAGE_CONFIG_EXPORT_RE = /export\s+const\s+pageConfig\s*=\s*/;
|
|
85
|
+
|
|
86
|
+
/** Matches `export default ` (RHS is read separately). */
|
|
87
|
+
const DEFAULT_EXPORT_RE = /export\s+default\s*/;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Locate `export default { ... }` and return the object literal substring,
|
|
91
|
+
* or null if no balanced object literal follows the `export default` keyword.
|
|
92
|
+
* Used by both manifest extraction and project validation.
|
|
93
|
+
*/
|
|
94
|
+
export function extractDefaultExportObjectLiteral(source: string): string | null {
|
|
95
|
+
const match = source.match(DEFAULT_EXPORT_RE);
|
|
96
|
+
if (!match || match.index === undefined) return null;
|
|
97
|
+
const startIndex = source.indexOf('{', match.index);
|
|
98
|
+
if (startIndex < 0) return null;
|
|
99
|
+
return extractObjectLiteral(source, startIndex);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read a _meta.js file and extract its default export object.
|
|
104
|
+
* Uses the same JSON5 approach as pageConfig extraction — find the object literal
|
|
105
|
+
* after `export default` and parse it.
|
|
106
|
+
*/
|
|
107
|
+
export function readMetaFile(metaPath: string): { title?: string; pages?: string[] } {
|
|
108
|
+
if (!existsSync(metaPath)) return {};
|
|
109
|
+
|
|
110
|
+
const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
|
|
111
|
+
if (!objectStr) return {};
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
return JSON5.parse(objectStr);
|
|
115
|
+
} catch {
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
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
|
+
|
|
126
|
+
const moduleScriptMatch = content.match(MODULE_SCRIPT_RE);
|
|
127
|
+
if (!moduleScriptMatch) return {};
|
|
128
|
+
|
|
129
|
+
const scriptContent = moduleScriptMatch[1];
|
|
130
|
+
|
|
131
|
+
const configMatch = scriptContent.match(PAGE_CONFIG_EXPORT_RE);
|
|
132
|
+
if (!configMatch || configMatch.index === undefined) return {};
|
|
133
|
+
|
|
134
|
+
const startIndex = scriptContent.indexOf('{', configMatch.index + configMatch[0].length);
|
|
135
|
+
if (startIndex < 0) return {};
|
|
136
|
+
const objectStr = extractObjectLiteral(scriptContent, startIndex);
|
|
137
|
+
if (!objectStr) return {};
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
return JSON5.parse(objectStr);
|
|
141
|
+
} catch {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`${filePath}: pageConfig must be a static object literal (no variables, function calls, or computed values)`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Extract an object literal from source starting at the opening brace.
|
|
150
|
+
* Tracks brace depth to find the matching closing brace.
|
|
151
|
+
*/
|
|
152
|
+
export function extractObjectLiteral(source: string, startIndex: number): string | null {
|
|
153
|
+
if (source[startIndex] !== '{') return null;
|
|
154
|
+
|
|
155
|
+
let depth = 0;
|
|
156
|
+
let inString: string | null = null;
|
|
157
|
+
let escaped = false;
|
|
158
|
+
|
|
159
|
+
for (let i = startIndex; i < source.length; i++) {
|
|
160
|
+
const char = source[i];
|
|
161
|
+
|
|
162
|
+
if (escaped) {
|
|
163
|
+
escaped = false;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (char === '\\' && inString) {
|
|
168
|
+
escaped = true;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (inString) {
|
|
173
|
+
if (char === inString) {
|
|
174
|
+
inString = null;
|
|
175
|
+
}
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
180
|
+
inString = char;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Skip single-line comments
|
|
185
|
+
if (char === '/' && i + 1 < source.length && source[i + 1] === '/') {
|
|
186
|
+
const newline = source.indexOf('\n', i);
|
|
187
|
+
i = newline === -1 ? source.length : newline;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Skip multi-line comments
|
|
192
|
+
if (char === '/' && i + 1 < source.length && source[i + 1] === '*') {
|
|
193
|
+
const end = source.indexOf('*/', i + 2);
|
|
194
|
+
i = end === -1 ? source.length : end + 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (char === '{') depth++;
|
|
199
|
+
if (char === '}') {
|
|
200
|
+
depth--;
|
|
201
|
+
if (depth === 0) {
|
|
202
|
+
return source.slice(startIndex, i + 1);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Get sorted subdirectories of a given path.
|
|
212
|
+
*/
|
|
213
|
+
function getSortedDirs(dirPath: string): string[] {
|
|
214
|
+
if (!existsSync(dirPath)) return [];
|
|
215
|
+
return readdirSync(dirPath)
|
|
216
|
+
.filter(name => {
|
|
217
|
+
const full = resolve(dirPath, name);
|
|
218
|
+
return statSync(full).isDirectory() && !name.startsWith('.');
|
|
219
|
+
})
|
|
220
|
+
.sort();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Get .svelte files in a directory.
|
|
225
|
+
*/
|
|
226
|
+
function getSvelteFiles(dirPath: string): string[] {
|
|
227
|
+
if (!existsSync(dirPath)) return [];
|
|
228
|
+
return readdirSync(dirPath)
|
|
229
|
+
.filter(name => name.endsWith('.svelte'))
|
|
230
|
+
.sort();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------- Main ----------
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Generate a course manifest by scanning the pages/ directory.
|
|
237
|
+
*/
|
|
238
|
+
export function generateManifest(pagesDir: string): Manifest {
|
|
239
|
+
const sections: ManifestSection[] = [];
|
|
240
|
+
const flatPages: ManifestPage[] = [];
|
|
241
|
+
let pageIndex = 0;
|
|
242
|
+
|
|
243
|
+
const sectionDirs = getSortedDirs(pagesDir);
|
|
244
|
+
|
|
245
|
+
for (const sectionName of sectionDirs) {
|
|
246
|
+
const sectionPath = resolve(pagesDir, sectionName);
|
|
247
|
+
const sectionMeta = readMetaFile(resolve(sectionPath, '_meta.js'));
|
|
248
|
+
const sectionSlug = deriveSlug(sectionName);
|
|
249
|
+
|
|
250
|
+
const section: ManifestSection = {
|
|
251
|
+
title: sectionMeta.title || titleCase(sectionSlug),
|
|
252
|
+
slug: sectionSlug,
|
|
253
|
+
lessons: [],
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const lessonDirs = getSortedDirs(sectionPath);
|
|
257
|
+
|
|
258
|
+
for (const lessonName of lessonDirs) {
|
|
259
|
+
const lessonPath = resolve(sectionPath, lessonName);
|
|
260
|
+
const lessonMeta = readMetaFile(resolve(lessonPath, '_meta.js'));
|
|
261
|
+
const lessonSlug = deriveSlug(lessonName);
|
|
262
|
+
|
|
263
|
+
const lesson: ManifestLesson = {
|
|
264
|
+
title: lessonMeta.title || titleCase(lessonSlug),
|
|
265
|
+
slug: lessonSlug,
|
|
266
|
+
pages: [],
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// Determine page order
|
|
270
|
+
const allSvelteFiles = getSvelteFiles(lessonPath);
|
|
271
|
+
const orderedFiles = orderPageFiles(allSvelteFiles, lessonMeta.pages);
|
|
272
|
+
|
|
273
|
+
for (const fileName of orderedFiles) {
|
|
274
|
+
const filePath = resolve(lessonPath, fileName);
|
|
275
|
+
const pageSlug = deriveSlug(fileName, true);
|
|
276
|
+
|
|
277
|
+
let pageConfig: { title?: string; quiz?: QuizConfig } = {};
|
|
278
|
+
try {
|
|
279
|
+
pageConfig = extractPageConfig(filePath);
|
|
280
|
+
} catch (e) {
|
|
281
|
+
// Validation errors will be handled by the validation plugin (Step 11).
|
|
282
|
+
// For now, log and continue with defaults.
|
|
283
|
+
console.warn(`[tessera warning] ${(e as Error).message}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const relativePath = `/pages/${sectionName}/${lessonName}/${fileName}`;
|
|
287
|
+
|
|
288
|
+
const page: ManifestPage = {
|
|
289
|
+
index: pageIndex,
|
|
290
|
+
title: pageConfig.title || titleCase(pageSlug),
|
|
291
|
+
slug: pageSlug,
|
|
292
|
+
importPath: relativePath,
|
|
293
|
+
quiz: pageConfig.quiz || null,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
lesson.pages.push(page);
|
|
297
|
+
flatPages.push(page);
|
|
298
|
+
pageIndex++;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
section.lessons.push(lesson);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
sections.push(section);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
sections,
|
|
309
|
+
pages: flatPages,
|
|
310
|
+
totalPages: flatPages.length,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Order .svelte files: listed in `pages` array first (in order), then unlisted appended alphabetically.
|
|
316
|
+
*/
|
|
317
|
+
export function orderPageFiles(allFiles: string[], pagesArray?: string[]): string[] {
|
|
318
|
+
if (!pagesArray || pagesArray.length === 0) {
|
|
319
|
+
return allFiles;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const listed = pagesArray.map(name => name.endsWith('.svelte') ? name : `${name}.svelte`);
|
|
323
|
+
const listedSet = new Set(listed);
|
|
324
|
+
const unlisted = allFiles.filter(f => !listedSet.has(f)).sort();
|
|
325
|
+
|
|
326
|
+
// Only include listed files that actually exist
|
|
327
|
+
const validListed = listed.filter(f => allFiles.includes(f));
|
|
328
|
+
|
|
329
|
+
return [...validListed, ...unlisted];
|
|
330
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { resolve, dirname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const VIRTUAL_QUIZ_ID = 'virtual:tessera-quiz';
|
|
7
|
+
const RESOLVED_QUIZ_ID = '\0' + VIRTUAL_QUIZ_ID;
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = dirname(__filename);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the project's quiz shell.
|
|
14
|
+
* `projectRoot/quiz.svelte` overrides the built-in `<Quiz>` if it exists,
|
|
15
|
+
* otherwise the built-in is used. Mirrors `tesseraLayoutPlugin` (Phase 3A).
|
|
16
|
+
*/
|
|
17
|
+
export function tesseraQuizPlugin(): Plugin {
|
|
18
|
+
let projectRoot: string;
|
|
19
|
+
// Resolve the built-in Quiz.svelte once. The plugin lives in
|
|
20
|
+
// `dist/plugin/quiz.js` after build and `src/plugin/quiz.ts` in source —
|
|
21
|
+
// both layouts put `Quiz.svelte` two levels up under `src/components/`.
|
|
22
|
+
const packageRoot = resolve(__dirname, '..', '..');
|
|
23
|
+
const builtinQuiz = resolve(packageRoot, 'src', 'components', 'Quiz.svelte');
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
name: 'tessera:quiz',
|
|
27
|
+
enforce: 'pre',
|
|
28
|
+
|
|
29
|
+
configResolved(config: ResolvedConfig) {
|
|
30
|
+
projectRoot = config.root;
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
resolveId(id) {
|
|
34
|
+
if (id === VIRTUAL_QUIZ_ID) return RESOLVED_QUIZ_ID;
|
|
35
|
+
return null;
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
load(id) {
|
|
39
|
+
if (id !== RESOLVED_QUIZ_ID) return null;
|
|
40
|
+
const userQuizPath = resolve(projectRoot, 'quiz.svelte');
|
|
41
|
+
if (existsSync(userQuizPath)) {
|
|
42
|
+
// Watch the user file so add/remove flips through HMR (see below).
|
|
43
|
+
this.addWatchFile(userQuizPath);
|
|
44
|
+
const normalized = userQuizPath.replace(/\\/g, '/');
|
|
45
|
+
return `export { default } from '${normalized}';`;
|
|
46
|
+
}
|
|
47
|
+
const normalized = builtinQuiz.replace(/\\/g, '/');
|
|
48
|
+
return `export { default } from '${normalized}';`;
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
configureServer(server: ViteDevServer) {
|
|
52
|
+
const userQuizPath = resolve(projectRoot, 'quiz.svelte');
|
|
53
|
+
// Only react to add/unlink — those flip the load() output between the
|
|
54
|
+
// user quiz and the built-in. A `change` event leaves the resolved
|
|
55
|
+
// module identical and is handled by Svelte's own HMR.
|
|
56
|
+
server.watcher.on('all', (event, filePath) => {
|
|
57
|
+
if (filePath !== userQuizPath) return;
|
|
58
|
+
if (event !== 'add' && event !== 'unlink') return;
|
|
59
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_QUIZ_ID);
|
|
60
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
61
|
+
server.ws.send({ type: 'full-reload' });
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|