@epic-web/workshop-utils 0.0.0-semantically-released

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