@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.
- package/README.md +3 -0
- package/dist/esm/apps.server.d.ts +4205 -0
- package/dist/esm/apps.server.d.ts.map +1 -0
- package/dist/esm/apps.server.js +1198 -0
- package/dist/esm/apps.server.js.map +1 -0
- package/dist/esm/cache.server.d.ts +940 -0
- package/dist/esm/cache.server.d.ts.map +1 -0
- package/dist/esm/cache.server.js +161 -0
- package/dist/esm/cache.server.js.map +1 -0
- package/dist/esm/compile-mdx.server.d.ts +12 -0
- package/dist/esm/compile-mdx.server.d.ts.map +1 -0
- package/dist/esm/compile-mdx.server.js +285 -0
- package/dist/esm/compile-mdx.server.js.map +1 -0
- package/dist/esm/config.server.d.ts +348 -0
- package/dist/esm/config.server.d.ts.map +1 -0
- package/dist/esm/config.server.js +231 -0
- package/dist/esm/config.server.js.map +1 -0
- package/dist/esm/db.server.d.ts +463 -0
- package/dist/esm/db.server.d.ts.map +1 -0
- package/dist/esm/db.server.js +260 -0
- package/dist/esm/db.server.js.map +1 -0
- package/dist/esm/diff.server.d.ts +18 -0
- package/dist/esm/diff.server.d.ts.map +1 -0
- package/dist/esm/diff.server.js +437 -0
- package/dist/esm/diff.server.js.map +1 -0
- package/dist/esm/env.server.d.ts +61 -0
- package/dist/esm/env.server.d.ts.map +1 -0
- package/dist/esm/env.server.js +42 -0
- package/dist/esm/env.server.js.map +1 -0
- package/dist/esm/epic-api.server.d.ts +227 -0
- package/dist/esm/epic-api.server.d.ts.map +1 -0
- package/dist/esm/epic-api.server.js +529 -0
- package/dist/esm/epic-api.server.js.map +1 -0
- package/dist/esm/git.server.d.ts +49 -0
- package/dist/esm/git.server.d.ts.map +1 -0
- package/dist/esm/git.server.js +135 -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 +97 -0
- package/dist/esm/iframe-sync.js.map +1 -0
- package/dist/esm/modified-time.server.d.ts +7 -0
- package/dist/esm/modified-time.server.d.ts.map +1 -0
- package/dist/esm/modified-time.server.js +80 -0
- package/dist/esm/modified-time.server.js.map +1 -0
- package/dist/esm/notifications.server.d.ts +56 -0
- package/dist/esm/notifications.server.d.ts.map +1 -0
- package/dist/esm/notifications.server.js +65 -0
- package/dist/esm/notifications.server.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 +95 -0
- package/dist/esm/playwright.server.js.map +1 -0
- package/dist/esm/process-manager.server.d.ts +77 -0
- package/dist/esm/process-manager.server.d.ts.map +1 -0
- package/dist/esm/process-manager.server.js +266 -0
- package/dist/esm/process-manager.server.js.map +1 -0
- package/dist/esm/test.d.ts +16 -0
- package/dist/esm/test.d.ts.map +1 -0
- package/dist/esm/test.js +56 -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 +88 -0
- package/dist/esm/timing.server.js.map +1 -0
- package/dist/esm/user.server.d.ts +17 -0
- package/dist/esm/user.server.d.ts.map +1 -0
- package/dist/esm/user.server.js +38 -0
- package/dist/esm/user.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 +9 -0
- package/dist/esm/utils.server.d.ts.map +1 -0
- package/dist/esm/utils.server.js +45 -0
- package/dist/esm/utils.server.js.map +1 -0
- 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
|