@epic-web/workshop-utils 4.0.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 (59) hide show
  1. package/README.md +3 -0
  2. package/dist/esm/apps.server.d.ts +3861 -0
  3. package/dist/esm/apps.server.d.ts.map +1 -0
  4. package/dist/esm/apps.server.js +1011 -0
  5. package/dist/esm/apps.server.js.map +1 -0
  6. package/dist/esm/cache.server.d.ts +798 -0
  7. package/dist/esm/cache.server.d.ts.map +1 -0
  8. package/dist/esm/cache.server.js +113 -0
  9. package/dist/esm/cache.server.js.map +1 -0
  10. package/dist/esm/change-tracker.server.d.ts +8 -0
  11. package/dist/esm/change-tracker.server.d.ts.map +1 -0
  12. package/dist/esm/change-tracker.server.js +32 -0
  13. package/dist/esm/change-tracker.server.js.map +1 -0
  14. package/dist/esm/codefile-mdx.server.d.ts +16 -0
  15. package/dist/esm/codefile-mdx.server.d.ts.map +1 -0
  16. package/dist/esm/codefile-mdx.server.js +275 -0
  17. package/dist/esm/codefile-mdx.server.js.map +1 -0
  18. package/dist/esm/compile-mdx.server.d.ts +11 -0
  19. package/dist/esm/compile-mdx.server.d.ts.map +1 -0
  20. package/dist/esm/compile-mdx.server.js +330 -0
  21. package/dist/esm/compile-mdx.server.js.map +1 -0
  22. package/dist/esm/db.server.d.ts +176 -0
  23. package/dist/esm/db.server.d.ts.map +1 -0
  24. package/dist/esm/db.server.js +203 -0
  25. package/dist/esm/db.server.js.map +1 -0
  26. package/dist/esm/git.server.d.ts +27 -0
  27. package/dist/esm/git.server.d.ts.map +1 -0
  28. package/dist/esm/git.server.js +93 -0
  29. package/dist/esm/git.server.js.map +1 -0
  30. package/dist/esm/iframe-sync.d.ts +10 -0
  31. package/dist/esm/iframe-sync.d.ts.map +1 -0
  32. package/dist/esm/iframe-sync.js +101 -0
  33. package/dist/esm/iframe-sync.js.map +1 -0
  34. package/dist/esm/package.json +3 -0
  35. package/dist/esm/playwright.server.d.ts +6 -0
  36. package/dist/esm/playwright.server.d.ts.map +1 -0
  37. package/dist/esm/playwright.server.js +94 -0
  38. package/dist/esm/playwright.server.js.map +1 -0
  39. package/dist/esm/process-manager.server.d.ts +78 -0
  40. package/dist/esm/process-manager.server.d.ts.map +1 -0
  41. package/dist/esm/process-manager.server.js +267 -0
  42. package/dist/esm/process-manager.server.js.map +1 -0
  43. package/dist/esm/test.d.ts +9 -0
  44. package/dist/esm/test.d.ts.map +1 -0
  45. package/dist/esm/test.js +45 -0
  46. package/dist/esm/test.js.map +1 -0
  47. package/dist/esm/timing.server.d.ts +20 -0
  48. package/dist/esm/timing.server.d.ts.map +1 -0
  49. package/dist/esm/timing.server.js +89 -0
  50. package/dist/esm/timing.server.js.map +1 -0
  51. package/dist/esm/utils.d.ts +2 -0
  52. package/dist/esm/utils.d.ts.map +1 -0
  53. package/dist/esm/utils.js +13 -0
  54. package/dist/esm/utils.js.map +1 -0
  55. package/dist/esm/utils.server.d.ts +3 -0
  56. package/dist/esm/utils.server.d.ts.map +1 -0
  57. package/dist/esm/utils.server.js +32 -0
  58. package/dist/esm/utils.server.js.map +1 -0
  59. package/package.json +171 -0
@@ -0,0 +1,1011 @@
1
+ /// TODO: figure out why this import is necessary (without it tsc seems to not honor the boolean reset 🤷‍♂️)
2
+ import '@total-typescript/ts-reset';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { remember } from '@epic-web/remember';
6
+ import { execa } from 'execa';
7
+ import fsExtra from 'fs-extra';
8
+ import { glob } from 'glob';
9
+ import { globby, isGitIgnored } from 'globby';
10
+ import { z } from 'zod';
11
+ import { appsCache, cachified, exampleAppCache, playgroundAppCache, problemAppCache, solutionAppCache, } from './cache.server.js';
12
+ import { getOptionalWatcher, getWatcher } from './change-tracker.server.js';
13
+ import { compileMdx } from './compile-mdx.server.js';
14
+ import { closeProcess, isAppRunning, runAppDev, waitOnApp, } from './process-manager.server.js';
15
+ import { getServerTimeHeader } from './timing.server.js';
16
+ import { getErrorMessage } from './utils.js';
17
+ import { getPkgProp } from './utils.server.js';
18
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
19
+ process.env.NODE_ENV ??= 'development';
20
+ const workshopRoot = getWorkshopRoot();
21
+ const playgroundAppNameInfoPath = path.join(getWorkshopRoot(), 'node_modules', '.cache', 'epicshop', 'playground.json');
22
+ const BaseAppSchema = z.object({
23
+ /** a unique identifier for the app */
24
+ name: z.string(),
25
+ /** the title of the app used for display (comes from the package.json title prop) */
26
+ title: z.string(),
27
+ /** used when displaying the list of files to match the list of apps in the file system (comes the name of the directory of the app) */
28
+ dirName: z.string(),
29
+ fullPath: z.string(),
30
+ relativePath: z.string(),
31
+ instructionsCode: z.string().optional(),
32
+ epicVideoEmbeds: z.array(z.string()).optional(),
33
+ test: z.union([
34
+ z.object({
35
+ type: z.literal('browser'),
36
+ pathname: z.string(),
37
+ testFiles: z.array(z.string()),
38
+ }),
39
+ z.object({ type: z.literal('script'), script: z.string() }),
40
+ z.object({ type: z.literal('none') }),
41
+ ]),
42
+ dev: z.union([
43
+ z.object({ type: z.literal('browser'), pathname: z.string() }),
44
+ z.object({ type: z.literal('script'), portNumber: z.number() }),
45
+ z.object({ type: z.literal('none') }),
46
+ ]),
47
+ });
48
+ const BaseExerciseStepAppSchema = BaseAppSchema.extend({
49
+ exerciseNumber: z.number(),
50
+ stepNumber: z.number(),
51
+ });
52
+ const ProblemAppSchema = BaseExerciseStepAppSchema.extend({
53
+ type: z.literal('problem'),
54
+ solutionName: z.string().nullable(),
55
+ });
56
+ const SolutionAppSchema = BaseExerciseStepAppSchema.extend({
57
+ type: z.literal('solution'),
58
+ problemName: z.string().nullable(),
59
+ });
60
+ const ExampleAppSchema = BaseAppSchema.extend({
61
+ type: z.literal('example'),
62
+ });
63
+ const PlaygroundAppSchema = BaseAppSchema.extend({
64
+ type: z.literal('playground'),
65
+ appName: z.string(),
66
+ });
67
+ const ExerciseSchema = z.object({
68
+ /** a unique identifier for the exercise */
69
+ exerciseNumber: z.number(),
70
+ /** used when displaying the list of files to match the list of apps in the file system (comes the name of the directory of the app) */
71
+ dirName: z.string(),
72
+ /** the title of the app used for display (comes from the first h1 in the README) */
73
+ title: z.string(),
74
+ instructionsCode: z.string().optional(),
75
+ finishedCode: z.string().optional(),
76
+ instructionsEpicVideoEmbeds: z.array(z.string()).optional(),
77
+ finishedEpicVideoEmbeds: z.array(z.string()).optional(),
78
+ steps: z.array(z.union([
79
+ z.object({
80
+ stepNumber: z.number(),
81
+ problem: ProblemAppSchema,
82
+ solution: SolutionAppSchema,
83
+ }),
84
+ z.object({
85
+ stepNumber: z.number(),
86
+ problem: ProblemAppSchema,
87
+ solution: z.never().optional(),
88
+ }),
89
+ z.object({
90
+ stepNumber: z.number(),
91
+ problem: z.never().optional(),
92
+ solution: SolutionAppSchema,
93
+ }),
94
+ ])),
95
+ problems: z.array(ProblemAppSchema),
96
+ solutions: z.array(SolutionAppSchema),
97
+ });
98
+ const ExerciseStepAppSchema = z.union([ProblemAppSchema, SolutionAppSchema]);
99
+ const AppSchema = z.union([
100
+ ExerciseStepAppSchema,
101
+ PlaygroundAppSchema,
102
+ ExampleAppSchema,
103
+ ]);
104
+ export function isApp(app) {
105
+ return AppSchema.safeParse(app).success;
106
+ }
107
+ export function isProblemApp(app) {
108
+ return ProblemAppSchema.safeParse(app).success;
109
+ }
110
+ export function isSolutionApp(app) {
111
+ return SolutionAppSchema.safeParse(app).success;
112
+ }
113
+ export function isFirstStepProblemApp(app) {
114
+ return isProblemApp(app) && app.stepNumber === 1;
115
+ }
116
+ export function isFirstStepSolutionApp(app) {
117
+ return isSolutionApp(app) && app.stepNumber === 1;
118
+ }
119
+ export function isPlaygroundApp(app) {
120
+ return isApp(app) && app.type === 'playground';
121
+ }
122
+ export function isExampleApp(app) {
123
+ return isApp(app) && app.type === 'example';
124
+ }
125
+ export function isExerciseStepApp(app) {
126
+ return isProblemApp(app) || isSolutionApp(app);
127
+ }
128
+ function exists(file) {
129
+ return fs.promises.access(file, fs.constants.F_OK).then(() => true, () => false);
130
+ }
131
+ function firstToExist(...files) {
132
+ return Promise.all(files.map(exists)).then(results => {
133
+ const index = results.findIndex(Boolean);
134
+ return index === -1 ? null : files[index];
135
+ });
136
+ }
137
+ export const modifiedTimes = remember('modified_times', () => new Map());
138
+ export function init() {
139
+ async function handleFileChanges(event, filePath) {
140
+ const apps = await getApps();
141
+ for (const app of apps) {
142
+ if (filePath.startsWith(app.fullPath)) {
143
+ modifiedTimes.set(app.fullPath, Date.now());
144
+ break;
145
+ }
146
+ }
147
+ }
148
+ getWatcher()?.on('all', handleFileChanges);
149
+ }
150
+ function getForceFresh(cacheEntry) {
151
+ if (!cacheEntry)
152
+ return true;
153
+ const latestModifiedTime = Math.max(...Array.from(modifiedTimes.values()));
154
+ if (!latestModifiedTime)
155
+ return undefined;
156
+ return latestModifiedTime > cacheEntry.metadata.createdTime ? true : undefined;
157
+ }
158
+ export function setModifiedTimesForDir(dir) {
159
+ modifiedTimes.set(dir, Date.now());
160
+ }
161
+ export function getForceFreshForDir(dir, cacheEntry) {
162
+ if (!path.isAbsolute(dir)) {
163
+ throw new Error(`Trying to get force fresh for non-absolute path: ${dir}`);
164
+ }
165
+ if (!cacheEntry)
166
+ return true;
167
+ const modifiedTime = modifiedTimes.get(dir);
168
+ if (!modifiedTime)
169
+ return undefined;
170
+ return modifiedTime > cacheEntry.metadata.createdTime ? true : undefined;
171
+ }
172
+ async function readDir(dir) {
173
+ if (await exists(dir)) {
174
+ return fs.promises.readdir(dir);
175
+ }
176
+ return [];
177
+ }
178
+ async function compileMdxIfExists(filepath, { request } = {}) {
179
+ filepath = filepath.replace(/\\/g, '/');
180
+ if (await exists(filepath)) {
181
+ const compiled = await compileMdx(filepath, { request }).catch(error => {
182
+ console.error(`Error compiling ${filepath}:`, error);
183
+ return null;
184
+ });
185
+ return compiled;
186
+ }
187
+ return null;
188
+ }
189
+ function getAppDirInfo(appDir) {
190
+ const regex = /^(?<stepNumber>\d+)\.(problem|solution)(\.(?<subtitle>.*))?$/;
191
+ const match = regex.exec(appDir);
192
+ if (!match?.groups) {
193
+ console.info(`Ignoring directory "${appDir}" which does not match regex "${regex}"`, new Error().stack);
194
+ return null;
195
+ }
196
+ const { stepNumber: stepNumberString, subtitle } = match.groups;
197
+ const stepNumber = Number(stepNumberString);
198
+ if (!stepNumber || !Number.isFinite(stepNumber)) {
199
+ throw new Error(`Cannot identify the stepNumber for app directory "${appDir}" with regex "${regex}"`);
200
+ }
201
+ const type = match[2];
202
+ return { stepNumber, type, subtitle };
203
+ }
204
+ function extractExerciseNumber(dir) {
205
+ const regex = /^(?<number>\d+)\./;
206
+ const number = regex.exec(dir)?.groups?.number;
207
+ if (!number) {
208
+ return null;
209
+ }
210
+ return Number(number);
211
+ }
212
+ export async function getExercises({ timings, request, } = {}) {
213
+ const apps = await getApps({ request, timings });
214
+ const exerciseDirs = await readDir(path.join(workshopRoot, 'exercises'));
215
+ const exercises = [];
216
+ for (const dirName of exerciseDirs) {
217
+ const exerciseNumber = extractExerciseNumber(dirName);
218
+ if (!exerciseNumber)
219
+ continue;
220
+ const compiledReadme = await compileMdxIfExists(path.join(workshopRoot, 'exercises', dirName, 'README.mdx'), { request });
221
+ const compiledFinished = await compileMdxIfExists(path.join(workshopRoot, 'exercises', dirName, 'FINISHED.mdx'), { request });
222
+ const steps = [];
223
+ const exerciseApps = apps
224
+ .filter(isExerciseStepApp)
225
+ .filter(app => app.exerciseNumber === exerciseNumber);
226
+ for (const app of exerciseApps) {
227
+ // @ts-ignore meh 🤷‍♂️
228
+ steps[app.stepNumber - 1] = {
229
+ ...steps[app.stepNumber - 1],
230
+ [app.type]: app,
231
+ stepNumber: app.stepNumber,
232
+ };
233
+ }
234
+ exercises.push({
235
+ exerciseNumber,
236
+ dirName,
237
+ instructionsCode: compiledReadme?.code,
238
+ finishedCode: compiledFinished?.code,
239
+ title: compiledReadme?.title ?? dirName,
240
+ instructionsEpicVideoEmbeds: compiledReadme?.epicVideoEmbeds,
241
+ finishedEpicVideoEmbeds: compiledFinished?.epicVideoEmbeds,
242
+ steps,
243
+ problems: apps
244
+ .filter(isProblemApp)
245
+ .filter(app => app.exerciseNumber === exerciseNumber),
246
+ solutions: apps
247
+ .filter(isSolutionApp)
248
+ .filter(app => app.exerciseNumber === exerciseNumber),
249
+ });
250
+ }
251
+ return exercises;
252
+ }
253
+ let appCallCount = 0;
254
+ export async function getApps({ timings, request, forceFresh, } = {}) {
255
+ const key = 'apps';
256
+ const apps = await cachified({
257
+ key,
258
+ cache: appsCache,
259
+ timings,
260
+ timingKey: `apps_${appCallCount++}`,
261
+ request,
262
+ // This entire cache is to avoid a single request getting a fresh value
263
+ // multiple times unnecessarily (because getApps is called many times)
264
+ ttl: 1000 * 60 * 60 * 24,
265
+ forceFresh: forceFresh ?? getForceFresh(appsCache.get(key)),
266
+ getFreshValue: async () => {
267
+ const playgroundApp = await getPlaygroundApp({ request, timings });
268
+ const problemApps = await getProblemApps({ request, timings });
269
+ const solutionApps = await getSolutionApps({ request, timings });
270
+ const exampleApps = await getExampleApps({ request, timings });
271
+ const sortedApps = [
272
+ playgroundApp,
273
+ ...problemApps,
274
+ ...solutionApps,
275
+ ...exampleApps,
276
+ ]
277
+ .filter(Boolean)
278
+ .sort((a, b) => {
279
+ if (isPlaygroundApp(a)) {
280
+ if (isPlaygroundApp(b))
281
+ return a.name.localeCompare(b.name);
282
+ else
283
+ return 1;
284
+ }
285
+ if (isPlaygroundApp(b))
286
+ return 1;
287
+ if (isExampleApp(a)) {
288
+ if (isExampleApp(b))
289
+ return a.name.localeCompare(b.name);
290
+ else
291
+ return 1;
292
+ }
293
+ if (isExampleApp(b))
294
+ return -1;
295
+ if (a.type === b.type) {
296
+ if (a.exerciseNumber === b.exerciseNumber) {
297
+ return a.stepNumber - b.stepNumber;
298
+ }
299
+ else {
300
+ return a.exerciseNumber - b.exerciseNumber;
301
+ }
302
+ }
303
+ // at this point, we know that a and b are different types...
304
+ if (isProblemApp(a)) {
305
+ if (a.exerciseNumber === b.exerciseNumber) {
306
+ return a.stepNumber <= b.stepNumber ? 1 : -1;
307
+ }
308
+ else {
309
+ return a.exerciseNumber <= b.exerciseNumber ? 1 : -1;
310
+ }
311
+ }
312
+ if (isSolutionApp(a)) {
313
+ if (a.exerciseNumber === b.exerciseNumber) {
314
+ return a.stepNumber < b.stepNumber ? -1 : 1;
315
+ }
316
+ else {
317
+ return a.exerciseNumber < b.exerciseNumber ? -1 : 1;
318
+ }
319
+ }
320
+ console.error('unhandled sorting case', a, b);
321
+ return 0;
322
+ });
323
+ return sortedApps;
324
+ },
325
+ });
326
+ return apps;
327
+ }
328
+ const AppIdInfoSchema = z.object({
329
+ exerciseNumber: z.string(),
330
+ stepNumber: z.string(),
331
+ type: z.union([z.literal('problem'), z.literal('solution')]),
332
+ });
333
+ /**
334
+ * Handles both full paths and app names
335
+ *
336
+ * @example
337
+ * extractNumbersAndTypeFromAppNameOrPath('02.01.problem') // { exerciseNumber: '02', stepNumber: '01', type: 'problem' }
338
+ * extractNumbersAndTypeFromAppNameOrPath('/path/to/exercises/02.desc/01.problem.desc') // { exerciseNumber: '02', stepNumber: '01', type: 'problem' }
339
+ */
340
+ export function extractNumbersAndTypeFromAppNameOrPath(fullPathOrAppName) {
341
+ const info = {};
342
+ if (fullPathOrAppName.includes(path.sep)) {
343
+ const relativePath = fullPathOrAppName.replace(path.join(workshopRoot, 'exercises', path.sep), '');
344
+ const [exerciseNumberPart, stepNumberPart] = relativePath.split(path.sep);
345
+ if (!exerciseNumberPart || !stepNumberPart)
346
+ return null;
347
+ const exerciseNumber = exerciseNumberPart.split('.')[0];
348
+ const stepNumber = stepNumberPart.split('.')[0];
349
+ const type = stepNumberPart.split('.')[1]?.split('.')[0];
350
+ info.exerciseNumber = exerciseNumber;
351
+ info.stepNumber = stepNumber;
352
+ info.type = type;
353
+ }
354
+ else {
355
+ const [exerciseNumber, stepNumber, type] = fullPathOrAppName.split('.');
356
+ info.exerciseNumber = exerciseNumber;
357
+ info.stepNumber = stepNumber;
358
+ info.type = type;
359
+ }
360
+ const result = AppIdInfoSchema.safeParse(info);
361
+ if (result.success)
362
+ return result.data;
363
+ return null;
364
+ }
365
+ async function getProblemDirs() {
366
+ const exercisesDir = path.join(workshopRoot, 'exercises');
367
+ const problemDirs = (await glob('**/*.problem*', {
368
+ cwd: exercisesDir,
369
+ ignore: 'node_modules/**',
370
+ })).map(p => path.join(exercisesDir, p));
371
+ return problemDirs;
372
+ }
373
+ async function getSolutionDirs() {
374
+ const exercisesDir = path.join(workshopRoot, 'exercises');
375
+ const solutionDirs = (await glob('**/*.solution*', {
376
+ cwd: exercisesDir,
377
+ ignore: 'node_modules/**',
378
+ })).map(p => path.join(exercisesDir, p));
379
+ return solutionDirs;
380
+ }
381
+ /**
382
+ * This is the pathname for the app in the browser
383
+ */
384
+ function getPathname(fullPath) {
385
+ const appName = getAppName(fullPath);
386
+ return `/app/${appName}/`;
387
+ }
388
+ function getAppName(fullPath) {
389
+ if (/playground\/?$/.test(fullPath))
390
+ return 'playground';
391
+ if (/examples\/.+\/?$/.test(fullPath)) {
392
+ const restOfPath = fullPath.replace(`${getWorkshopRoot()}${path.sep}examples${path.sep}`, '');
393
+ return `example.${restOfPath.split(path.sep).join('__sep__')}`;
394
+ }
395
+ const appIdInfo = extractNumbersAndTypeFromAppNameOrPath(fullPath);
396
+ if (appIdInfo) {
397
+ const { exerciseNumber, stepNumber, type } = appIdInfo;
398
+ return `${exerciseNumber}.${stepNumber}.${type}`;
399
+ }
400
+ else {
401
+ const relativePath = fullPath.replace(`${workshopRoot}${path.sep}`, '');
402
+ return relativePath.split(path.sep).join('__sep__');
403
+ }
404
+ }
405
+ async function getFullPathFromAppName(appName) {
406
+ if (appName === 'playground')
407
+ return path.join(workshopRoot, 'playground');
408
+ if (appName.startsWith('.example')) {
409
+ const relativePath = appName
410
+ .replace('.example', '')
411
+ .split('__sep__')
412
+ .join(path.sep);
413
+ return path.join(workshopRoot, 'examples', relativePath);
414
+ }
415
+ if (appName.includes('__sep__')) {
416
+ const relativePath = appName.replaceAll('__sep__', path.sep);
417
+ return path.join(workshopRoot, relativePath);
418
+ }
419
+ const [exerciseNumber, stepNumber, type] = appName.split('.');
420
+ const appDirs = type === 'problem'
421
+ ? await getProblemDirs()
422
+ : type === 'solution'
423
+ ? await getSolutionDirs()
424
+ : [];
425
+ const dir = appDirs.find(dir => {
426
+ const info = extractNumbersAndTypeFromAppNameOrPath(dir);
427
+ if (!info)
428
+ return false;
429
+ return (info.exerciseNumber === exerciseNumber && info.stepNumber === stepNumber);
430
+ });
431
+ return dir ?? appName;
432
+ }
433
+ async function findSolutionDir({ fullPath, }) {
434
+ const dirName = path.basename(fullPath);
435
+ if (dirName.includes('.problem')) {
436
+ const info = getAppDirInfo(dirName);
437
+ if (!info)
438
+ return null;
439
+ const { stepNumber } = info;
440
+ const paddedStepNumber = stepNumber.toString().padStart(2, '0');
441
+ const parentDir = path.dirname(fullPath);
442
+ const siblingDirs = await fs.promises.readdir(parentDir);
443
+ const solutionDir = siblingDirs.find(dir => dir.startsWith(`${paddedStepNumber}.solution`));
444
+ if (solutionDir) {
445
+ return path.join(parentDir, solutionDir);
446
+ }
447
+ }
448
+ else if (fullPath.endsWith('playground')) {
449
+ const appName = await getPlaygroundAppName();
450
+ if (appName) {
451
+ return findSolutionDir({
452
+ fullPath: await getFullPathFromAppName(appName),
453
+ });
454
+ }
455
+ }
456
+ return null;
457
+ }
458
+ async function findProblemDir({ fullPath, }) {
459
+ const dirName = path.basename(fullPath);
460
+ if (dirName.includes('.solution')) {
461
+ const info = getAppDirInfo(dirName);
462
+ if (!info)
463
+ return null;
464
+ const { stepNumber } = info;
465
+ const paddedStepNumber = stepNumber.toString().padStart(2, '0');
466
+ const parentDir = path.dirname(fullPath);
467
+ const siblingDirs = await fs.promises.readdir(parentDir);
468
+ const problemDir = siblingDirs.find(dir => dir.endsWith('problem') && dir.includes(paddedStepNumber));
469
+ if (problemDir) {
470
+ return path.join(parentDir, problemDir);
471
+ }
472
+ }
473
+ else if (fullPath.endsWith('playground')) {
474
+ const appName = await getPlaygroundAppName();
475
+ if (appName) {
476
+ return findProblemDir({ fullPath: await getFullPathFromAppName(appName) });
477
+ }
478
+ }
479
+ return null;
480
+ }
481
+ async function getTestInfo({ fullPath, }) {
482
+ const testScriptName = 'test';
483
+ const hasPkgJson = await exists(path.join(fullPath, 'package.json'));
484
+ const testScript = hasPkgJson
485
+ ? await getPkgProp(fullPath, ['epicshop.scripts', testScriptName].join('.'), '')
486
+ : null;
487
+ if (testScript) {
488
+ return { type: 'script', script: testScript };
489
+ }
490
+ // tests are found in the corresponding solution directory
491
+ const testAppFullPath = (await findSolutionDir({ fullPath })) ?? fullPath;
492
+ const dirList = await fs.promises.readdir(testAppFullPath);
493
+ const testFiles = dirList.filter(item => item.includes('.test.'));
494
+ if (testFiles.length) {
495
+ return {
496
+ type: 'browser',
497
+ pathname: `${getPathname(fullPath)}test/`,
498
+ testFiles,
499
+ };
500
+ }
501
+ return { type: 'none' };
502
+ }
503
+ async function getDevInfo({ fullPath, portNumber, }) {
504
+ const hasPkgJson = await exists(path.join(fullPath, 'package.json'));
505
+ const hasDevScript = hasPkgJson
506
+ ? Boolean(await getPkgProp(fullPath, ['scripts', 'dev'].join('.'), ''))
507
+ : false;
508
+ if (hasDevScript) {
509
+ return { type: 'script', portNumber };
510
+ }
511
+ const indexFiles = (await fsExtra.readdir(fullPath)).filter((file) => file.startsWith('index.'));
512
+ if (indexFiles.length) {
513
+ return { type: 'browser', pathname: getPathname(fullPath) };
514
+ }
515
+ else {
516
+ return { type: 'none' };
517
+ }
518
+ }
519
+ export async function getPlaygroundApp({ timings, request, } = {}) {
520
+ const playgroundDir = path.join(workshopRoot, 'playground');
521
+ const appName = await getPlaygroundAppName();
522
+ const key = `playground-${appName}`;
523
+ return cachified({
524
+ key,
525
+ cache: playgroundAppCache,
526
+ ttl: 1000 * 60 * 60 * 24,
527
+ timings,
528
+ timingKey: playgroundDir.replace(`${playgroundDir}${path.sep}`, ''),
529
+ request,
530
+ forceFresh: getForceFreshForDir(playgroundDir, playgroundAppCache.get(key)),
531
+ getFreshValue: async () => {
532
+ if (!(await exists(playgroundDir)))
533
+ return null;
534
+ if (!appName)
535
+ return null;
536
+ const dirName = path.basename(playgroundDir);
537
+ const name = getAppName(playgroundDir);
538
+ const portNumber = 4000;
539
+ const [compiledReadme, test, dev] = await Promise.all([
540
+ compileMdxIfExists(path.join(playgroundDir, 'README.mdx'), { request }),
541
+ getTestInfo({ fullPath: playgroundDir }),
542
+ getDevInfo({ fullPath: playgroundDir, portNumber }),
543
+ ]);
544
+ return {
545
+ name,
546
+ appName,
547
+ type: 'playground',
548
+ fullPath: playgroundDir,
549
+ relativePath: playgroundDir.replace(`${getWorkshopRoot()}${path.sep}`, ''),
550
+ title: compiledReadme?.title ?? name,
551
+ epicVideoEmbeds: compiledReadme?.epicVideoEmbeds,
552
+ dirName,
553
+ instructionsCode: compiledReadme?.code,
554
+ test,
555
+ dev,
556
+ };
557
+ },
558
+ }).catch(error => {
559
+ console.error(error);
560
+ return null;
561
+ });
562
+ }
563
+ async function getExampleAppFromPath(fullPath, index, request) {
564
+ const dirName = path.basename(fullPath);
565
+ const compiledReadme = await compileMdxIfExists(path.join(fullPath, 'README.mdx'), { request });
566
+ const name = getAppName(fullPath);
567
+ const portNumber = 8000 + index;
568
+ return {
569
+ name,
570
+ type: 'example',
571
+ fullPath,
572
+ relativePath: fullPath.replace(`${getWorkshopRoot()}${path.sep}`, ''),
573
+ title: compiledReadme?.title ?? name,
574
+ epicVideoEmbeds: compiledReadme?.epicVideoEmbeds,
575
+ dirName,
576
+ instructionsCode: compiledReadme?.code,
577
+ test: await getTestInfo({ fullPath }),
578
+ dev: await getDevInfo({ fullPath, portNumber }),
579
+ };
580
+ }
581
+ async function getExampleApps({ timings, request, } = {}) {
582
+ const examplesDir = path.join(workshopRoot, 'examples');
583
+ const exampleDirs = (await glob('*', { cwd: examplesDir, ignore: 'node_modules/**' })).map(p => path.join(examplesDir, p));
584
+ const exampleApps = [];
585
+ for (const exampleDir of exampleDirs) {
586
+ const index = exampleDirs.indexOf(exampleDir);
587
+ const key = `${exampleDir}-${index}`;
588
+ const exampleApp = await cachified({
589
+ key,
590
+ cache: exampleAppCache,
591
+ ttl: 1000 * 60 * 60 * 24,
592
+ timings,
593
+ timingKey: exampleDir.replace(`${examplesDir}${path.sep}`, ''),
594
+ request,
595
+ forceFresh: getForceFreshForDir(exampleDir, exampleAppCache.get(key)),
596
+ getFreshValue: () => getExampleAppFromPath(exampleDir, index, request).catch(error => {
597
+ console.error(error);
598
+ return null;
599
+ }),
600
+ });
601
+ if (exampleApp)
602
+ exampleApps.push(exampleApp);
603
+ }
604
+ return exampleApps;
605
+ }
606
+ async function getSolutionAppFromPath(fullPath, request) {
607
+ const dirName = path.basename(fullPath);
608
+ const parentDirName = path.basename(path.dirname(fullPath));
609
+ const exerciseNumber = extractExerciseNumber(parentDirName);
610
+ if (!exerciseNumber)
611
+ return null;
612
+ const name = getAppName(fullPath);
613
+ const info = getAppDirInfo(dirName);
614
+ if (!info)
615
+ return null;
616
+ const { stepNumber } = info;
617
+ const portNumber = 7000 + (exerciseNumber - 1) * 10 + stepNumber;
618
+ const compiledReadme = await compileMdxIfExists(path.join(fullPath, 'README.mdx'), { request });
619
+ const problemDir = await findProblemDir({
620
+ fullPath,
621
+ });
622
+ const problemName = problemDir ? getAppName(problemDir) : null;
623
+ const [test, dev] = await Promise.all([
624
+ getTestInfo({ fullPath }),
625
+ getDevInfo({ fullPath, portNumber }),
626
+ ]);
627
+ return {
628
+ name,
629
+ title: compiledReadme?.title ?? name,
630
+ epicVideoEmbeds: compiledReadme?.epicVideoEmbeds,
631
+ type: 'solution',
632
+ problemName,
633
+ exerciseNumber,
634
+ stepNumber,
635
+ dirName,
636
+ fullPath,
637
+ relativePath: fullPath.replace(`${getWorkshopRoot()}${path.sep}`, ''),
638
+ instructionsCode: compiledReadme?.code,
639
+ test,
640
+ dev,
641
+ };
642
+ }
643
+ async function getSolutionApps({ timings, request, } = {}) {
644
+ const exercisesDir = path.join(workshopRoot, 'exercises');
645
+ const solutionDirs = await getSolutionDirs();
646
+ const solutionApps = [];
647
+ for (const solutionDir of solutionDirs) {
648
+ const solutionApp = await cachified({
649
+ key: solutionDir,
650
+ cache: solutionAppCache,
651
+ timings,
652
+ timingKey: solutionDir.replace(`${exercisesDir}${path.sep}`, ''),
653
+ request,
654
+ ttl: 1000 * 60 * 60 * 24,
655
+ forceFresh: getForceFreshForDir(solutionDir, solutionAppCache.get(solutionDir)),
656
+ getFreshValue: () => getSolutionAppFromPath(solutionDir, request).catch(error => {
657
+ console.error(error);
658
+ return null;
659
+ }),
660
+ });
661
+ if (solutionApp)
662
+ solutionApps.push(solutionApp);
663
+ }
664
+ return solutionApps;
665
+ }
666
+ async function getProblemAppFromPath(fullPath, request) {
667
+ const dirName = path.basename(fullPath);
668
+ const parentDirName = path.basename(path.dirname(fullPath));
669
+ const exerciseNumber = extractExerciseNumber(parentDirName);
670
+ if (!exerciseNumber)
671
+ return null;
672
+ const name = getAppName(fullPath);
673
+ const info = getAppDirInfo(dirName);
674
+ if (!info)
675
+ return null;
676
+ const { stepNumber } = info;
677
+ const portNumber = 6000 + (exerciseNumber - 1) * 10 + stepNumber;
678
+ const compiledReadme = await compileMdxIfExists(path.join(fullPath, 'README.mdx'), { request });
679
+ const solutionDir = await findSolutionDir({
680
+ fullPath,
681
+ });
682
+ const solutionName = solutionDir ? getAppName(solutionDir) : null;
683
+ const [test, dev] = await Promise.all([
684
+ getTestInfo({ fullPath }),
685
+ getDevInfo({ fullPath, portNumber }),
686
+ ]);
687
+ return {
688
+ solutionName,
689
+ name,
690
+ title: compiledReadme?.title ?? name,
691
+ epicVideoEmbeds: compiledReadme?.epicVideoEmbeds,
692
+ type: 'problem',
693
+ exerciseNumber,
694
+ stepNumber,
695
+ dirName,
696
+ fullPath,
697
+ relativePath: fullPath.replace(`${getWorkshopRoot()}${path.sep}`, ''),
698
+ instructionsCode: compiledReadme?.code,
699
+ test,
700
+ dev,
701
+ };
702
+ }
703
+ async function getProblemApps({ timings, request, } = {}) {
704
+ const exercisesDir = path.join(workshopRoot, 'exercises');
705
+ const problemDirs = await getProblemDirs();
706
+ const problemApps = [];
707
+ for (const problemDir of problemDirs) {
708
+ const problemApp = await cachified({
709
+ key: problemDir,
710
+ cache: problemAppCache,
711
+ timings,
712
+ timingKey: problemDir.replace(`${exercisesDir}${path.sep}`, ''),
713
+ request,
714
+ ttl: 1000 * 60 * 60 * 24,
715
+ forceFresh: getForceFreshForDir(problemDir, problemAppCache.get(problemDir)),
716
+ getFreshValue: () => getProblemAppFromPath(problemDir).catch(error => {
717
+ console.error(error);
718
+ return null;
719
+ }),
720
+ });
721
+ if (problemApp)
722
+ problemApps.push(problemApp);
723
+ }
724
+ return problemApps;
725
+ }
726
+ export async function getExercise(exerciseNumber, { request, timings } = {}) {
727
+ const exercises = await getExercises({ request, timings });
728
+ return exercises.find(s => s.exerciseNumber === Number(exerciseNumber));
729
+ }
730
+ export async function requireExercise(exerciseNumber, { request, timings } = {}) {
731
+ const exercise = await getExercise(exerciseNumber, { request, timings });
732
+ if (!exercise) {
733
+ throw new Response('Not found', {
734
+ status: 404,
735
+ headers: { 'Server-Timing': getServerTimeHeader(timings) },
736
+ });
737
+ }
738
+ return exercise;
739
+ }
740
+ export async function requireExerciseApp(params, { request, timings } = {}) {
741
+ const app = await getExerciseApp(params, { request, timings });
742
+ if (!app) {
743
+ throw new Response('Not found', { status: 404 });
744
+ }
745
+ return app;
746
+ }
747
+ const ExerciseAppParamsSchema = z.object({
748
+ type: z.union([z.literal('problem'), z.literal('solution')]),
749
+ exerciseNumber: z.coerce.number().finite(),
750
+ stepNumber: z.coerce.number().finite(),
751
+ });
752
+ export async function getExerciseApp(params, { request, timings } = {}) {
753
+ const result = ExerciseAppParamsSchema.safeParse(params);
754
+ if (!result.success) {
755
+ return null;
756
+ }
757
+ const { type, exerciseNumber, stepNumber } = result.data;
758
+ const apps = (await getApps({ request, timings })).filter(isExerciseStepApp);
759
+ const exerciseApp = apps.find(app => {
760
+ if (isExampleApp(app))
761
+ return false;
762
+ return (app.exerciseNumber === exerciseNumber &&
763
+ app.stepNumber === stepNumber &&
764
+ app.type === type);
765
+ });
766
+ if (!exerciseApp) {
767
+ return null;
768
+ }
769
+ return exerciseApp;
770
+ }
771
+ export async function getAppByName(name, { request, timings } = {}) {
772
+ const apps = await getApps({ request, timings });
773
+ return apps.find(a => a.name === name);
774
+ }
775
+ export async function getNextExerciseApp(app, { request, timings } = {}) {
776
+ const apps = (await getApps({ request, timings })).filter(isExerciseStepApp);
777
+ const index = apps.findIndex(a => a.name === app.name);
778
+ if (index === -1) {
779
+ throw new Error(`Could not find app ${app.name}`);
780
+ }
781
+ const nextApp = apps[index + 1];
782
+ return nextApp ? nextApp : null;
783
+ }
784
+ export async function getPrevExerciseApp(app, { request, timings } = {}) {
785
+ const apps = (await getApps({ request, timings })).filter(isExerciseStepApp);
786
+ const index = apps.findIndex(a => a.name === app.name);
787
+ if (index === -1) {
788
+ throw new Error(`Could not find app ${app.name}`);
789
+ }
790
+ const prevApp = apps[index - 1];
791
+ return prevApp ? prevApp : null;
792
+ }
793
+ export function getAppPageRoute(app) {
794
+ const exerciseNumber = app.exerciseNumber.toString().padStart(2, '0');
795
+ const stepNumber = app.stepNumber.toString().padStart(2, '0');
796
+ return `/${exerciseNumber}/${stepNumber}/${app.type}`;
797
+ }
798
+ /**
799
+ * Given a file path, this will find the app that file path belongs to.
800
+ */
801
+ export async function getAppFromFile(filePath) {
802
+ const apps = await getApps();
803
+ return apps.find(app => filePath.startsWith(app.fullPath));
804
+ }
805
+ export async function setPlayground(srcDir, { reset } = {}) {
806
+ const isIgnored = await isGitIgnored({ cwd: srcDir });
807
+ const workshopRoot = getWorkshopRoot();
808
+ const destDir = path.join(workshopRoot, 'playground');
809
+ const playgroundFiles = path.join(destDir, '**');
810
+ getOptionalWatcher()?.unwatch(playgroundFiles);
811
+ const playgroundApp = await getAppByName('playground');
812
+ const playgroundWasRunning = playgroundApp
813
+ ? isAppRunning(playgroundApp)
814
+ : false;
815
+ if (playgroundApp && reset) {
816
+ await closeProcess(playgroundApp.name);
817
+ await fsExtra.remove(destDir);
818
+ }
819
+ const setPlaygroundTimestamp = Date.now();
820
+ // run prepare-playground script if it exists
821
+ const preSetPlaygroundPath = await firstToExist(path.join(srcDir, 'epicshop', 'pre-set-playground.js'), path.join(workshopRoot, 'epicshop', 'pre-set-playground.js'));
822
+ if (preSetPlaygroundPath) {
823
+ await execa('node', [preSetPlaygroundPath], {
824
+ cwd: workshopRoot,
825
+ stdio: 'inherit',
826
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
827
+ env: {
828
+ EPICSHOP_PLAYGROUND_TIMESTAMP: setPlaygroundTimestamp.toString(),
829
+ EPICSHOP_PLAYGROUND_DEST_DIR: destDir,
830
+ EPICSHOP_PLAYGROUND_SRC_DIR: srcDir,
831
+ EPICSHOP_PLAYGROUND_WAS_RUNNING: playgroundWasRunning.toString(),
832
+ },
833
+ });
834
+ }
835
+ const basename = path.basename(srcDir);
836
+ // If we don't delete the destination node_modules first then copying the new
837
+ // node_modules has issues.
838
+ await fsExtra.remove(path.join(destDir, 'node_modules'));
839
+ // Copy the contents of the source directory to the destination directory recursively
840
+ await fsExtra.copy(srcDir, destDir, {
841
+ filter: async (srcFile, destFile) => {
842
+ if (srcFile.includes(`${basename}${path.sep}build`) ||
843
+ srcFile.includes(`${basename}${path.sep}public${path.sep}build`)) {
844
+ return false;
845
+ }
846
+ if (srcFile === srcDir)
847
+ return true;
848
+ // we copy node_modules even though it's .gitignored
849
+ if (srcFile.includes('node_modules'))
850
+ return true;
851
+ // make sure .env is copied whether it's .gitignored or not
852
+ if (srcFile.endsWith('.env'))
853
+ return true;
854
+ if (isIgnored(srcFile))
855
+ return false;
856
+ try {
857
+ const isDir = (await fsExtra.stat(srcFile)).isDirectory();
858
+ if (isDir)
859
+ return true;
860
+ const destIsDir = (await fsExtra.stat(destFile)).isDirectory();
861
+ // weird, but ok
862
+ if (destIsDir)
863
+ return true;
864
+ // it's better to check if the contents are the same before copying
865
+ // because it avoids unnecessary writes and reduces the impact on any
866
+ // file watchers (like the remix dev server). In practice, it's definitely
867
+ // slower, but it's better because it doesn't cause the dev server to
868
+ // crash as often.
869
+ const currentContents = await fsExtra.readFile(destFile);
870
+ const newContents = await fsExtra.readFile(srcFile);
871
+ if (currentContents.equals(newContents))
872
+ return false;
873
+ return true;
874
+ }
875
+ catch {
876
+ // 🤷‍♂️ should probably copy it in this case
877
+ return true;
878
+ }
879
+ },
880
+ });
881
+ async function getFiles(dir) {
882
+ // make globby friendly to windows
883
+ const dirPath = dir.replace(/\\/g, '/');
884
+ const files = await globby([`${dirPath}/**/*`, '!**/build/**/*'], {
885
+ onlyFiles: false,
886
+ dot: true,
887
+ });
888
+ return files.map(f => f.replace(dirPath, ''));
889
+ }
890
+ // Remove files from destDir that were in destDir before but are not in srcDir
891
+ const srcFiles = await getFiles(srcDir);
892
+ const destFiles = await getFiles(destDir);
893
+ const filesToDelete = destFiles.filter(fileName => !srcFiles.includes(fileName));
894
+ for (const fileToDelete of filesToDelete) {
895
+ await fsExtra.remove(path.join(destDir, fileToDelete));
896
+ }
897
+ const appName = getAppName(srcDir);
898
+ await fsExtra.ensureDir(path.dirname(playgroundAppNameInfoPath));
899
+ await fsExtra.writeJSON(playgroundAppNameInfoPath, { appName });
900
+ const playgroundIsStillRunning = playgroundApp
901
+ ? isAppRunning(playgroundApp)
902
+ : false;
903
+ const restartPlayground = playgroundWasRunning && !playgroundIsStillRunning;
904
+ // run postSet-playground script if it exists
905
+ const postSetPlaygroundPath = await firstToExist(path.join(srcDir, 'epicshop', 'post-set-playground.js'), path.join(workshopRoot, 'epicshop', 'post-set-playground.js'));
906
+ if (postSetPlaygroundPath) {
907
+ await execa('node', [postSetPlaygroundPath], {
908
+ cwd: workshopRoot,
909
+ stdio: 'inherit',
910
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
911
+ env: {
912
+ EPICSHOP_PLAYGROUND_TIMESTAMP: setPlaygroundTimestamp.toString(),
913
+ EPICSHOP_PLAYGROUND_SRC_DIR: srcDir,
914
+ EPICSHOP_PLAYGROUND_DEST_DIR: destDir,
915
+ EPICSHOP_PLAYGROUND_WAS_RUNNING: playgroundWasRunning.toString(),
916
+ EPICSHOP_PLAYGROUND_IS_STILL_RUNNING: playgroundIsStillRunning.toString(),
917
+ EPICSHOP_PLAYGROUND_RESTART_PLAYGROUND: restartPlayground.toString(),
918
+ },
919
+ });
920
+ }
921
+ if (playgroundApp && restartPlayground) {
922
+ await runAppDev(playgroundApp);
923
+ await waitOnApp(playgroundApp);
924
+ }
925
+ getOptionalWatcher()?.add(playgroundFiles);
926
+ modifiedTimes.set(destDir, Date.now());
927
+ }
928
+ /**
929
+ * The playground is based on another app. This returns the app the playground
930
+ * is based on.
931
+ */
932
+ export async function getPlaygroundAppName() {
933
+ if (!(await exists(playgroundAppNameInfoPath))) {
934
+ return null;
935
+ }
936
+ try {
937
+ const jsonString = await fs.promises.readFile(playgroundAppNameInfoPath, 'utf8');
938
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
939
+ const { appName } = JSON.parse(jsonString);
940
+ if (typeof appName !== 'string')
941
+ return null;
942
+ return appName;
943
+ }
944
+ catch {
945
+ return null;
946
+ }
947
+ }
948
+ export function getAppDisplayName(a, allApps) {
949
+ let displayName = `${a.title} (${a.type})`;
950
+ if (isExerciseStepApp(a)) {
951
+ const typeLabel = { problem: '💪', solution: '🏁' }[a.type];
952
+ displayName = `${a.exerciseNumber}.${a.stepNumber} ${a.title} (${typeLabel} ${a.type})`;
953
+ }
954
+ else if (isPlaygroundApp(a)) {
955
+ const playgroundAppBasis = allApps.find(otherApp => a.appName === otherApp.name);
956
+ if (playgroundAppBasis) {
957
+ const basisDisplayName = getAppDisplayName(playgroundAppBasis, allApps);
958
+ displayName = `🛝 ${basisDisplayName}`;
959
+ }
960
+ else {
961
+ displayName = `🛝 ${a.appName}`;
962
+ }
963
+ }
964
+ else if (isExampleApp(a)) {
965
+ displayName = `📚 ${a.title} (example)`;
966
+ }
967
+ return displayName;
968
+ }
969
+ export async function getWorkshopTitle() {
970
+ const title = await getPkgProp(workshopRoot, 'epicshop.title');
971
+ if (!title) {
972
+ throw new Error(`Workshop title not found. Make sure the root of the workshop has "epicshop" with a "title" property in the package.json. ${workshopRoot}`);
973
+ }
974
+ return title;
975
+ }
976
+ export async function getEpicWorkshopSlug() {
977
+ const epicWorkshopSlug = await getPkgProp(workshopRoot, 'epicshop.epicWorkshopSlug', '');
978
+ return epicWorkshopSlug || null;
979
+ }
980
+ export function getWorkshopRoot() {
981
+ return process.env.EPICSHOP_CONTEXT_CWD ?? process.cwd();
982
+ }
983
+ export async function getWorkshopInstructions({ request, } = {}) {
984
+ const readmeFilepath = path.join(workshopRoot, 'exercises', 'README.mdx');
985
+ const compiled = await compileMdx(readmeFilepath, { request }).then(r => ({ ...r, status: 'success' }), e => {
986
+ console.error(`There was an error compiling the workshop readme`, readmeFilepath, e);
987
+ return { status: 'error', error: getErrorMessage(e) };
988
+ });
989
+ return { compiled, file: readmeFilepath, relativePath: 'exercises' };
990
+ }
991
+ export async function getWorkshopFinished({ request, } = {}) {
992
+ const finishedFilepath = path.join(workshopRoot, 'exercises', 'FINISHED.mdx');
993
+ const compiled = await compileMdx(finishedFilepath, { request }).then(r => ({ ...r, status: 'success' }), e => {
994
+ console.error(`There was an error compiling the workshop finished.mdx`, finishedFilepath, e);
995
+ return { status: 'error', error: getErrorMessage(e) };
996
+ });
997
+ return {
998
+ compiled,
999
+ file: finishedFilepath,
1000
+ relativePath: 'exercises/finished.mdx',
1001
+ };
1002
+ }
1003
+ const exercisesPath = path.join(workshopRoot, 'exercises/');
1004
+ const playgroundPath = path.join(workshopRoot, 'playground/');
1005
+ export function getRelativePath(filePath) {
1006
+ return path
1007
+ .normalize(filePath)
1008
+ .replace(playgroundPath, `playground${path.sep}`)
1009
+ .replace(exercisesPath, '');
1010
+ }
1011
+ //# sourceMappingURL=apps.server.js.map