@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.
- package/README.md +3 -0
- package/dist/esm/apps.server.d.ts +3861 -0
- package/dist/esm/apps.server.d.ts.map +1 -0
- package/dist/esm/apps.server.js +1011 -0
- package/dist/esm/apps.server.js.map +1 -0
- package/dist/esm/cache.server.d.ts +798 -0
- package/dist/esm/cache.server.d.ts.map +1 -0
- package/dist/esm/cache.server.js +113 -0
- package/dist/esm/cache.server.js.map +1 -0
- package/dist/esm/change-tracker.server.d.ts +8 -0
- package/dist/esm/change-tracker.server.d.ts.map +1 -0
- package/dist/esm/change-tracker.server.js +32 -0
- package/dist/esm/change-tracker.server.js.map +1 -0
- package/dist/esm/codefile-mdx.server.d.ts +16 -0
- package/dist/esm/codefile-mdx.server.d.ts.map +1 -0
- package/dist/esm/codefile-mdx.server.js +275 -0
- package/dist/esm/codefile-mdx.server.js.map +1 -0
- package/dist/esm/compile-mdx.server.d.ts +11 -0
- package/dist/esm/compile-mdx.server.d.ts.map +1 -0
- package/dist/esm/compile-mdx.server.js +330 -0
- package/dist/esm/compile-mdx.server.js.map +1 -0
- package/dist/esm/db.server.d.ts +176 -0
- package/dist/esm/db.server.d.ts.map +1 -0
- package/dist/esm/db.server.js +203 -0
- package/dist/esm/db.server.js.map +1 -0
- package/dist/esm/git.server.d.ts +27 -0
- package/dist/esm/git.server.d.ts.map +1 -0
- package/dist/esm/git.server.js +93 -0
- package/dist/esm/git.server.js.map +1 -0
- package/dist/esm/iframe-sync.d.ts +10 -0
- package/dist/esm/iframe-sync.d.ts.map +1 -0
- package/dist/esm/iframe-sync.js +101 -0
- package/dist/esm/iframe-sync.js.map +1 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/playwright.server.d.ts +6 -0
- package/dist/esm/playwright.server.d.ts.map +1 -0
- package/dist/esm/playwright.server.js +94 -0
- package/dist/esm/playwright.server.js.map +1 -0
- package/dist/esm/process-manager.server.d.ts +78 -0
- package/dist/esm/process-manager.server.d.ts.map +1 -0
- package/dist/esm/process-manager.server.js +267 -0
- package/dist/esm/process-manager.server.js.map +1 -0
- package/dist/esm/test.d.ts +9 -0
- package/dist/esm/test.d.ts.map +1 -0
- package/dist/esm/test.js +45 -0
- package/dist/esm/test.js.map +1 -0
- package/dist/esm/timing.server.d.ts +20 -0
- package/dist/esm/timing.server.d.ts.map +1 -0
- package/dist/esm/timing.server.js +89 -0
- package/dist/esm/timing.server.js.map +1 -0
- package/dist/esm/utils.d.ts +2 -0
- package/dist/esm/utils.d.ts.map +1 -0
- package/dist/esm/utils.js +13 -0
- package/dist/esm/utils.js.map +1 -0
- package/dist/esm/utils.server.d.ts +3 -0
- package/dist/esm/utils.server.d.ts.map +1 -0
- package/dist/esm/utils.server.js +32 -0
- package/dist/esm/utils.server.js.map +1 -0
- 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
|