epicshop 6.84.7 → 6.85.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/dist/cli.js +19 -2
- package/dist/commands/admin/launch-readiness.js +27 -143
- package/dist/commands/admin/set-videos.d.ts +20 -0
- package/dist/commands/admin/set-videos.js +462 -0
- package/dist/commands/admin/workshop-content-utils.d.ts +33 -0
- package/dist/commands/admin/workshop-content-utils.js +168 -0
- package/dist/commands/admin.d.ts +1 -0
- package/dist/commands/admin.js +1 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -742,7 +742,7 @@ const cli = yargs(args)
|
|
|
742
742
|
.positional('subcommand', {
|
|
743
743
|
describe: 'Admin subcommand',
|
|
744
744
|
type: 'string',
|
|
745
|
-
choices: ['launch-readiness'],
|
|
745
|
+
choices: ['launch-readiness', 'set-videos'],
|
|
746
746
|
})
|
|
747
747
|
.option('workshop-dir', {
|
|
748
748
|
alias: 'w',
|
|
@@ -765,7 +765,14 @@ const cli = yargs(args)
|
|
|
765
765
|
description: 'Skip checking that EpicVideo urls return 200 to HEAD (network required)',
|
|
766
766
|
default: false,
|
|
767
767
|
})
|
|
768
|
-
.
|
|
768
|
+
.option('dry-run', {
|
|
769
|
+
type: 'boolean',
|
|
770
|
+
description: 'Preview set-videos changes without writing files (set-videos only)',
|
|
771
|
+
default: false,
|
|
772
|
+
})
|
|
773
|
+
.example('$0 admin launch-readiness', 'Check workshop launch readiness (hidden command)')
|
|
774
|
+
.example('$0 admin set-videos', 'Set top EpicVideo embeds from product lesson order (hidden command)')
|
|
775
|
+
.example('$0 admin set-videos --dry-run', 'Preview top EpicVideo changes without writing files');
|
|
769
776
|
}, async (argv) => {
|
|
770
777
|
const { findWorkshopRoot } = await import("./commands/workshops.js");
|
|
771
778
|
const workshopRoot = await findWorkshopRoot(resolveWorkshopContextCwd(argv.workshopDir));
|
|
@@ -789,6 +796,16 @@ const cli = yargs(args)
|
|
|
789
796
|
process.exit(1);
|
|
790
797
|
break;
|
|
791
798
|
}
|
|
799
|
+
case 'set-videos': {
|
|
800
|
+
const { setVideos } = await import("./commands/admin.js");
|
|
801
|
+
const result = await setVideos({
|
|
802
|
+
silent: argv.silent,
|
|
803
|
+
dryRun: argv.dryRun,
|
|
804
|
+
});
|
|
805
|
+
if (!result.success)
|
|
806
|
+
process.exit(1);
|
|
807
|
+
break;
|
|
808
|
+
}
|
|
792
809
|
default: {
|
|
793
810
|
console.error(chalk.red(`❌ Unknown admin subcommand: ${argv.subcommand}`));
|
|
794
811
|
process.exit(1);
|
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { compileMdx } from '@epic-web/workshop-utils/compile-mdx.server';
|
|
4
|
-
import { getAuthInfo } from '@epic-web/workshop-utils/db.server';
|
|
5
4
|
import { getErrorMessage } from '@epic-web/workshop-utils/utils';
|
|
6
5
|
import chalk from 'chalk';
|
|
7
6
|
import { pathExists } from "../../utils/filesystem.js";
|
|
8
|
-
|
|
9
|
-
// EpicAI embeds sometimes include a `~...` suffix in the slug segment.
|
|
10
|
-
return value.replace(/~[^ ]*$/, '');
|
|
11
|
-
}
|
|
7
|
+
import { collectStepDirectories, formatProductLessonUrl, fetchRemoteWorkshopLessons, isDirectory, resolveMdxFile, stripEpicAiSlugSuffix, } from "./workshop-content-utils.js";
|
|
12
8
|
function normalizeHost(host) {
|
|
13
9
|
return host.toLowerCase().replace(/^www\./, '');
|
|
14
10
|
}
|
|
@@ -59,10 +55,6 @@ function parseEpicLessonSlugFromEmbedUrl(urlString) {
|
|
|
59
55
|
return parseSegments(segments);
|
|
60
56
|
}
|
|
61
57
|
}
|
|
62
|
-
function formatProductLessonUrl({ productHost, productSlug, lessonSlug, }) {
|
|
63
|
-
// The product site will typically redirect to a section-specific path when needed.
|
|
64
|
-
return `https://${productHost}/workshops/${productSlug}/${lessonSlug}`;
|
|
65
|
-
}
|
|
66
58
|
function formatIssue(issue, workshopRoot) {
|
|
67
59
|
const icon = issue.level === 'error' ? chalk.red('❌') : chalk.yellow('⚠️ ');
|
|
68
60
|
const filePart = issue.file
|
|
@@ -70,20 +62,6 @@ function formatIssue(issue, workshopRoot) {
|
|
|
70
62
|
: '';
|
|
71
63
|
return `${icon} ${issue.message}${filePart}`;
|
|
72
64
|
}
|
|
73
|
-
async function isDirectory(targetPath) {
|
|
74
|
-
try {
|
|
75
|
-
return (await fs.stat(targetPath)).isDirectory();
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
return false;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
async function resolveMdxFile(dir, baseName) {
|
|
82
|
-
const mdx = path.join(dir, `${baseName}.mdx`);
|
|
83
|
-
if (await pathExists(mdx))
|
|
84
|
-
return mdx;
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
65
|
async function buildExpectedFiles({ workshopRoot, exerciseDirName, }) {
|
|
88
66
|
const issues = [];
|
|
89
67
|
const files = [];
|
|
@@ -142,29 +120,7 @@ async function buildExpectedFiles({ workshopRoot, exerciseDirName, }) {
|
|
|
142
120
|
});
|
|
143
121
|
return { files, contentFiles, issues };
|
|
144
122
|
}
|
|
145
|
-
const
|
|
146
|
-
const stepsByNumber = new Map();
|
|
147
|
-
for (const entry of entries) {
|
|
148
|
-
if (!entry.isDirectory())
|
|
149
|
-
continue;
|
|
150
|
-
const match = stepDirRegex.exec(entry.name);
|
|
151
|
-
if (!match?.groups)
|
|
152
|
-
continue;
|
|
153
|
-
const stepNumber = Number(match.groups.stepNumber);
|
|
154
|
-
const type = match.groups.type;
|
|
155
|
-
if (!Number.isFinite(stepNumber) || stepNumber <= 0)
|
|
156
|
-
continue;
|
|
157
|
-
const current = stepsByNumber.get(stepNumber) ?? {
|
|
158
|
-
problems: [],
|
|
159
|
-
solutions: [],
|
|
160
|
-
};
|
|
161
|
-
const fullStepDir = path.join(exerciseRoot, entry.name);
|
|
162
|
-
if (type === 'problem')
|
|
163
|
-
current.problems.push(fullStepDir);
|
|
164
|
-
if (type === 'solution')
|
|
165
|
-
current.solutions.push(fullStepDir);
|
|
166
|
-
stepsByNumber.set(stepNumber, current);
|
|
167
|
-
}
|
|
123
|
+
const stepsByNumber = collectStepDirectories(entries, exerciseRoot);
|
|
168
124
|
if (stepsByNumber.size === 0) {
|
|
169
125
|
issues.push({
|
|
170
126
|
level: 'warning',
|
|
@@ -253,96 +209,6 @@ async function buildExpectedFiles({ workshopRoot, exerciseDirName, }) {
|
|
|
253
209
|
}
|
|
254
210
|
return { files, contentFiles, issues };
|
|
255
211
|
}
|
|
256
|
-
async function fetchRemoteWorkshopLessonSlugs({ productHost, workshopSlug, }) {
|
|
257
|
-
const url = `https://${productHost}/api/workshops/${encodeURIComponent(workshopSlug)}`;
|
|
258
|
-
const fetchOnce = async (accessToken) => {
|
|
259
|
-
const timeout = AbortSignal.timeout(15_000);
|
|
260
|
-
const headers = {};
|
|
261
|
-
if (accessToken)
|
|
262
|
-
headers.authorization = `Bearer ${accessToken}`;
|
|
263
|
-
return fetch(url, { headers, signal: timeout });
|
|
264
|
-
};
|
|
265
|
-
let response = null;
|
|
266
|
-
try {
|
|
267
|
-
response = await fetchOnce();
|
|
268
|
-
}
|
|
269
|
-
catch (error) {
|
|
270
|
-
return {
|
|
271
|
-
status: 'error',
|
|
272
|
-
message: `Failed to fetch product workshop data: ${getErrorMessage(error)}`,
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
if (response.status === 401 || response.status === 403) {
|
|
276
|
-
const authInfo = await getAuthInfo({ productHost }).catch(() => null);
|
|
277
|
-
const accessToken = authInfo?.tokenSet?.access_token;
|
|
278
|
-
if (accessToken) {
|
|
279
|
-
try {
|
|
280
|
-
response = await fetchOnce(accessToken);
|
|
281
|
-
}
|
|
282
|
-
catch (error) {
|
|
283
|
-
return {
|
|
284
|
-
status: 'error',
|
|
285
|
-
message: `Failed to fetch product workshop data (after auth): ${getErrorMessage(error)}`,
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
if (!response.ok) {
|
|
291
|
-
const body = await response.text().catch(() => '');
|
|
292
|
-
const hint = response.status === 401 || response.status === 403
|
|
293
|
-
? ` (try: npx epicshop auth login ${productHost.replace(/^www\./, '')})`
|
|
294
|
-
: response.status === 404
|
|
295
|
-
? ` (check epicshop.product.host + epicshop.product.slug)`
|
|
296
|
-
: '';
|
|
297
|
-
return {
|
|
298
|
-
status: 'error',
|
|
299
|
-
message: `Product API request failed: ${response.status} ${response.statusText}${hint}${body ? `\n${body}` : ''}`,
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
let data;
|
|
303
|
-
try {
|
|
304
|
-
data = await response.json();
|
|
305
|
-
}
|
|
306
|
-
catch (error) {
|
|
307
|
-
return {
|
|
308
|
-
status: 'error',
|
|
309
|
-
message: `Product API response was not valid JSON: ${getErrorMessage(error)}`,
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
const resources = data?.resources;
|
|
313
|
-
if (!Array.isArray(resources)) {
|
|
314
|
-
return {
|
|
315
|
-
status: 'error',
|
|
316
|
-
message: `Product API response did not include an array "resources" field`,
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
const lessonSlugs = [];
|
|
320
|
-
for (const resource of resources) {
|
|
321
|
-
if (!resource || typeof resource !== 'object')
|
|
322
|
-
continue;
|
|
323
|
-
const r = resource;
|
|
324
|
-
if (r._type === 'lesson') {
|
|
325
|
-
const slug = r.slug;
|
|
326
|
-
if (typeof slug === 'string')
|
|
327
|
-
lessonSlugs.push(slug);
|
|
328
|
-
continue;
|
|
329
|
-
}
|
|
330
|
-
if (r._type === 'section') {
|
|
331
|
-
const lessons = r.lessons;
|
|
332
|
-
if (!Array.isArray(lessons))
|
|
333
|
-
continue;
|
|
334
|
-
for (const lesson of lessons) {
|
|
335
|
-
if (!lesson || typeof lesson !== 'object')
|
|
336
|
-
continue;
|
|
337
|
-
const l = lesson;
|
|
338
|
-
const slug = l.slug;
|
|
339
|
-
if (typeof slug === 'string')
|
|
340
|
-
lessonSlugs.push(slug);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
return { status: 'success', lessonSlugs };
|
|
345
|
-
}
|
|
346
212
|
async function checkMinContentLength({ fullPath, minChars, }) {
|
|
347
213
|
try {
|
|
348
214
|
const raw = await fs.readFile(fullPath, 'utf8');
|
|
@@ -838,7 +704,7 @@ export async function launchReadiness(options = {}) {
|
|
|
838
704
|
}
|
|
839
705
|
localProductLessonSlugs.add(lessonSlug);
|
|
840
706
|
}
|
|
841
|
-
const remote = await
|
|
707
|
+
const remote = await fetchRemoteWorkshopLessons({
|
|
842
708
|
productHost,
|
|
843
709
|
workshopSlug: productSlug,
|
|
844
710
|
});
|
|
@@ -850,7 +716,21 @@ export async function launchReadiness(options = {}) {
|
|
|
850
716
|
});
|
|
851
717
|
}
|
|
852
718
|
else {
|
|
853
|
-
const
|
|
719
|
+
const remoteLessons = remote.lessons
|
|
720
|
+
.map((l) => ({
|
|
721
|
+
slug: stripEpicAiSlugSuffix(l.slug),
|
|
722
|
+
sectionSlug: l.sectionSlug
|
|
723
|
+
? stripEpicAiSlugSuffix(l.sectionSlug)
|
|
724
|
+
: null,
|
|
725
|
+
}))
|
|
726
|
+
.filter((l) => l.slug.trim().length > 0);
|
|
727
|
+
// Preserve the first sectionSlug seen for a given lesson slug.
|
|
728
|
+
const remoteLessonBySlug = new Map();
|
|
729
|
+
for (const l of remoteLessons) {
|
|
730
|
+
if (!remoteLessonBySlug.has(l.slug))
|
|
731
|
+
remoteLessonBySlug.set(l.slug, l);
|
|
732
|
+
}
|
|
733
|
+
const remoteLessonSlugs = [...remoteLessonBySlug.keys()];
|
|
854
734
|
if (remoteLessonSlugs.length === 0) {
|
|
855
735
|
issues.push({
|
|
856
736
|
level: 'error',
|
|
@@ -862,11 +742,15 @@ export async function launchReadiness(options = {}) {
|
|
|
862
742
|
if (missing.length) {
|
|
863
743
|
const formatted = missing
|
|
864
744
|
.sort()
|
|
865
|
-
.map((slug) =>
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
745
|
+
.map((slug) => {
|
|
746
|
+
const remoteLesson = remoteLessonBySlug.get(slug);
|
|
747
|
+
return `- ${slug}: ${formatProductLessonUrl({
|
|
748
|
+
productHost,
|
|
749
|
+
productSlug,
|
|
750
|
+
lessonSlug: slug,
|
|
751
|
+
sectionSlug: remoteLesson?.sectionSlug ?? null,
|
|
752
|
+
})}`;
|
|
753
|
+
})
|
|
870
754
|
.join('\n');
|
|
871
755
|
issues.push({
|
|
872
756
|
level: 'error',
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type SetVideosOptions = {
|
|
2
|
+
/**
|
|
3
|
+
* Defaults to `process.env.EPICSHOP_CONTEXT_CWD ?? process.cwd()`.
|
|
4
|
+
* Primarily useful for tests.
|
|
5
|
+
*/
|
|
6
|
+
workshopRoot?: string;
|
|
7
|
+
silent?: boolean;
|
|
8
|
+
dryRun?: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type SetVideosResult = {
|
|
11
|
+
success: boolean;
|
|
12
|
+
message: string;
|
|
13
|
+
error?: Error;
|
|
14
|
+
inserted: number;
|
|
15
|
+
updated: number;
|
|
16
|
+
unchanged: number;
|
|
17
|
+
warnings: Array<string>;
|
|
18
|
+
dryRun: boolean;
|
|
19
|
+
};
|
|
20
|
+
export declare function setVideos(options?: SetVideosOptions): Promise<SetVideosResult>;
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getErrorMessage } from '@epic-web/workshop-utils/utils';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { collectStepDirectories, formatProductLessonUrl, fetchRemoteWorkshopLessons, isDirectory, resolveMdxFile, stripEpicAiSlugSuffix, } from "./workshop-content-utils.js";
|
|
6
|
+
function createFailureResult(message, { dryRun = false } = {}) {
|
|
7
|
+
return {
|
|
8
|
+
success: false,
|
|
9
|
+
message,
|
|
10
|
+
error: new Error(message),
|
|
11
|
+
inserted: 0,
|
|
12
|
+
updated: 0,
|
|
13
|
+
unchanged: 0,
|
|
14
|
+
warnings: [],
|
|
15
|
+
dryRun,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
async function collectOrderedVideoFiles({ workshopRoot, }) {
|
|
19
|
+
const files = [];
|
|
20
|
+
const errors = [];
|
|
21
|
+
const warnings = [];
|
|
22
|
+
const exercisesRoot = path.join(workshopRoot, 'exercises');
|
|
23
|
+
if (!(await isDirectory(exercisesRoot))) {
|
|
24
|
+
errors.push('Missing `exercises/` directory (required for a workshop)');
|
|
25
|
+
return { files, errors, warnings };
|
|
26
|
+
}
|
|
27
|
+
const workshopIntro = await resolveMdxFile(exercisesRoot, 'README');
|
|
28
|
+
if (!workshopIntro) {
|
|
29
|
+
errors.push('Missing workshop intro file `exercises/README.mdx`');
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
files.push({
|
|
33
|
+
kind: 'workshop-intro',
|
|
34
|
+
fullPath: workshopIntro,
|
|
35
|
+
relativePath: path.relative(workshopRoot, workshopIntro),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const workshopWrapUp = await resolveMdxFile(exercisesRoot, 'FINISHED');
|
|
39
|
+
if (!workshopWrapUp) {
|
|
40
|
+
errors.push('Missing workshop wrap-up file `exercises/FINISHED.mdx`');
|
|
41
|
+
}
|
|
42
|
+
const exerciseEntries = await fs.readdir(exercisesRoot, {
|
|
43
|
+
withFileTypes: true,
|
|
44
|
+
});
|
|
45
|
+
const exerciseDirNames = exerciseEntries
|
|
46
|
+
.filter((e) => e.isDirectory() && /^\d+\./.test(e.name))
|
|
47
|
+
.map((e) => e.name)
|
|
48
|
+
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
49
|
+
if (exerciseDirNames.length === 0) {
|
|
50
|
+
warnings.push('No exercise directories found (expected folders like "01.my-exercise" under exercises/)');
|
|
51
|
+
}
|
|
52
|
+
for (const exerciseDirName of exerciseDirNames) {
|
|
53
|
+
const exerciseRoot = path.join(exercisesRoot, exerciseDirName);
|
|
54
|
+
const exerciseNumberMatch = /^(\d+)\./.exec(exerciseDirName);
|
|
55
|
+
const exerciseNumber = exerciseNumberMatch
|
|
56
|
+
? Number(exerciseNumberMatch[1])
|
|
57
|
+
: undefined;
|
|
58
|
+
const exerciseIntro = await resolveMdxFile(exerciseRoot, 'README');
|
|
59
|
+
if (!exerciseIntro) {
|
|
60
|
+
errors.push(`Missing exercise intro file (expected README.mdx): ${path.relative(workshopRoot, path.join(exerciseRoot, 'README.mdx'))}`);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
files.push({
|
|
64
|
+
kind: 'exercise-intro',
|
|
65
|
+
fullPath: exerciseIntro,
|
|
66
|
+
relativePath: path.relative(workshopRoot, exerciseIntro),
|
|
67
|
+
exerciseNumber,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
let entries = [];
|
|
71
|
+
try {
|
|
72
|
+
entries = await fs.readdir(exerciseRoot, { withFileTypes: true });
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
errors.push(`Failed to read exercise directory "${path.relative(workshopRoot, exerciseRoot)}": ${getErrorMessage(error)}`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const stepsByNumber = collectStepDirectories(entries, exerciseRoot);
|
|
79
|
+
if (stepsByNumber.size === 0) {
|
|
80
|
+
warnings.push(`No step app directories found in "${path.relative(workshopRoot, exerciseRoot)}" (expected folders like "01.problem" and "01.solution")`);
|
|
81
|
+
}
|
|
82
|
+
for (const [stepNumber, dirs] of [...stepsByNumber.entries()].sort((a, b) => a[0] - b[0])) {
|
|
83
|
+
if (dirs.problems.length === 0) {
|
|
84
|
+
errors.push(`Missing problem app directory for step ${stepNumber} in ${path.relative(workshopRoot, exerciseRoot)}`);
|
|
85
|
+
}
|
|
86
|
+
if (dirs.solutions.length === 0) {
|
|
87
|
+
errors.push(`Missing solution app directory for step ${stepNumber} in ${path.relative(workshopRoot, exerciseRoot)}`);
|
|
88
|
+
}
|
|
89
|
+
if (dirs.problems.length > 1) {
|
|
90
|
+
warnings.push(`Multiple problem app directories found for step ${stepNumber} in ${path.relative(workshopRoot, exerciseRoot)}`);
|
|
91
|
+
}
|
|
92
|
+
if (dirs.solutions.length > 1) {
|
|
93
|
+
warnings.push(`Multiple solution app directories found for step ${stepNumber} in ${path.relative(workshopRoot, exerciseRoot)}`);
|
|
94
|
+
}
|
|
95
|
+
for (const problemDir of [...dirs.problems].sort((a, b) => a.localeCompare(b))) {
|
|
96
|
+
const problemReadme = await resolveMdxFile(problemDir, 'README');
|
|
97
|
+
if (!problemReadme) {
|
|
98
|
+
errors.push(`Missing step problem README.mdx: ${path.relative(workshopRoot, path.join(problemDir, 'README.mdx'))}`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
files.push({
|
|
102
|
+
kind: 'step-problem',
|
|
103
|
+
fullPath: problemReadme,
|
|
104
|
+
relativePath: path.relative(workshopRoot, problemReadme),
|
|
105
|
+
exerciseNumber,
|
|
106
|
+
stepNumber,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
for (const solutionDir of [...dirs.solutions].sort((a, b) => a.localeCompare(b))) {
|
|
110
|
+
const solutionReadme = await resolveMdxFile(solutionDir, 'README');
|
|
111
|
+
if (!solutionReadme) {
|
|
112
|
+
errors.push(`Missing step solution README.mdx: ${path.relative(workshopRoot, path.join(solutionDir, 'README.mdx'))}`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
files.push({
|
|
116
|
+
kind: 'step-solution',
|
|
117
|
+
fullPath: solutionReadme,
|
|
118
|
+
relativePath: path.relative(workshopRoot, solutionReadme),
|
|
119
|
+
exerciseNumber,
|
|
120
|
+
stepNumber,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const exerciseSummary = await resolveMdxFile(exerciseRoot, 'FINISHED');
|
|
125
|
+
if (!exerciseSummary) {
|
|
126
|
+
errors.push(`Missing exercise summary file (expected FINISHED.mdx): ${path.relative(workshopRoot, path.join(exerciseRoot, 'FINISHED.mdx'))}`);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
files.push({
|
|
130
|
+
kind: 'exercise-summary',
|
|
131
|
+
fullPath: exerciseSummary,
|
|
132
|
+
relativePath: path.relative(workshopRoot, exerciseSummary),
|
|
133
|
+
exerciseNumber,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (workshopWrapUp) {
|
|
138
|
+
files.push({
|
|
139
|
+
kind: 'workshop-wrap-up',
|
|
140
|
+
fullPath: workshopWrapUp,
|
|
141
|
+
relativePath: path.relative(workshopRoot, workshopWrapUp),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return { files, errors, warnings };
|
|
145
|
+
}
|
|
146
|
+
function setEpicVideoUrl({ epicVideoBlock, url, }) {
|
|
147
|
+
const openingTagMatch = epicVideoBlock.match(/<EpicVideo\b[\s\S]*?>/);
|
|
148
|
+
if (!openingTagMatch) {
|
|
149
|
+
return epicVideoBlock;
|
|
150
|
+
}
|
|
151
|
+
const openingTag = openingTagMatch[0];
|
|
152
|
+
const urlAttrMatch = openingTag.match(/\burl\s*=\s*("([^"]*)"|'([^']*)')/);
|
|
153
|
+
let nextOpeningTag = openingTag;
|
|
154
|
+
if (urlAttrMatch) {
|
|
155
|
+
const currentUrl = urlAttrMatch[2] ?? urlAttrMatch[3] ?? '';
|
|
156
|
+
if (currentUrl === url)
|
|
157
|
+
return epicVideoBlock;
|
|
158
|
+
const quote = urlAttrMatch[1]?.startsWith("'") ? "'" : '"';
|
|
159
|
+
nextOpeningTag = openingTag.replace(urlAttrMatch[0], `url=${quote}${url}${quote}`);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
nextOpeningTag = openingTag.replace(/\/?>$/, (suffix) => {
|
|
163
|
+
return ` url="${url}"${suffix}`;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return epicVideoBlock.replace(openingTag, nextOpeningTag);
|
|
167
|
+
}
|
|
168
|
+
function upsertTitleEpicVideo({ content, url, }) {
|
|
169
|
+
const lineEnding = content.includes('\r\n') ? '\r\n' : '\n';
|
|
170
|
+
const lines = content.split(/\r?\n/);
|
|
171
|
+
const titleLineIndex = lines.findIndex((line) => /^\s{0,3}#\s+/.test(line));
|
|
172
|
+
if (titleLineIndex < 0) {
|
|
173
|
+
return {
|
|
174
|
+
status: 'error',
|
|
175
|
+
message: 'Missing top-level H1 title (`# ...`)',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
let firstNonEmptyAfterTitle = titleLineIndex + 1;
|
|
179
|
+
while (firstNonEmptyAfterTitle < lines.length &&
|
|
180
|
+
lines[firstNonEmptyAfterTitle]?.trim() === '') {
|
|
181
|
+
firstNonEmptyAfterTitle++;
|
|
182
|
+
}
|
|
183
|
+
const lineAfterTitle = lines[firstNonEmptyAfterTitle]?.trimStart() ?? '';
|
|
184
|
+
if (lineAfterTitle.startsWith('<EpicVideo')) {
|
|
185
|
+
const blockStart = firstNonEmptyAfterTitle;
|
|
186
|
+
let blockEnd = -1;
|
|
187
|
+
for (let index = blockStart; index < lines.length; index++) {
|
|
188
|
+
const line = lines[index]?.trim() ?? '';
|
|
189
|
+
if (line.includes('/>') || line.includes('</EpicVideo>')) {
|
|
190
|
+
blockEnd = index;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (blockEnd < 0) {
|
|
195
|
+
return {
|
|
196
|
+
status: 'error',
|
|
197
|
+
message: 'Found a top EpicVideo block but could not find its closing tag',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const currentBlock = lines.slice(blockStart, blockEnd + 1).join(lineEnding);
|
|
201
|
+
const nextBlock = setEpicVideoUrl({ epicVideoBlock: currentBlock, url });
|
|
202
|
+
if (nextBlock === currentBlock) {
|
|
203
|
+
return {
|
|
204
|
+
status: 'success',
|
|
205
|
+
outcome: 'unchanged',
|
|
206
|
+
nextContent: content,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const nextLines = [
|
|
210
|
+
...lines.slice(0, blockStart),
|
|
211
|
+
...nextBlock.split(/\r?\n/),
|
|
212
|
+
...lines.slice(blockEnd + 1),
|
|
213
|
+
];
|
|
214
|
+
return {
|
|
215
|
+
status: 'success',
|
|
216
|
+
outcome: 'updated',
|
|
217
|
+
nextContent: nextLines.join(lineEnding),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const epicVideoLine = `<EpicVideo url="${url}" />`;
|
|
221
|
+
const nextLines = [
|
|
222
|
+
...lines.slice(0, titleLineIndex + 1),
|
|
223
|
+
'',
|
|
224
|
+
epicVideoLine,
|
|
225
|
+
'',
|
|
226
|
+
...lines.slice(firstNonEmptyAfterTitle),
|
|
227
|
+
];
|
|
228
|
+
return {
|
|
229
|
+
status: 'success',
|
|
230
|
+
outcome: 'inserted',
|
|
231
|
+
nextContent: nextLines.join(lineEnding),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function formatNumberedList(items, { startAt = 1 } = {}) {
|
|
235
|
+
return items.map((item, index) => `${startAt + index}. ${item}`).join('\n');
|
|
236
|
+
}
|
|
237
|
+
function buildFileLessonSlotPlans(files) {
|
|
238
|
+
const plans = [];
|
|
239
|
+
const stepSlotByKey = new Map();
|
|
240
|
+
let nextLessonSlotIndex = 0;
|
|
241
|
+
for (const file of files) {
|
|
242
|
+
if ((file.kind === 'step-problem' || file.kind === 'step-solution') &&
|
|
243
|
+
typeof file.exerciseNumber === 'number' &&
|
|
244
|
+
typeof file.stepNumber === 'number') {
|
|
245
|
+
const key = `${file.exerciseNumber}:${file.stepNumber}`;
|
|
246
|
+
const existingSlot = stepSlotByKey.get(key);
|
|
247
|
+
if (typeof existingSlot === 'number') {
|
|
248
|
+
plans.push({ file, lessonSlotIndex: existingSlot });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const newSlot = nextLessonSlotIndex++;
|
|
252
|
+
stepSlotByKey.set(key, newSlot);
|
|
253
|
+
plans.push({ file, lessonSlotIndex: newSlot });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const slot = nextLessonSlotIndex++;
|
|
257
|
+
plans.push({ file, lessonSlotIndex: slot });
|
|
258
|
+
}
|
|
259
|
+
return { plans, requiredLessonSlots: nextLessonSlotIndex };
|
|
260
|
+
}
|
|
261
|
+
export async function setVideos(options = {}) {
|
|
262
|
+
const workshopRoot = path.resolve(options.workshopRoot ?? process.env.EPICSHOP_CONTEXT_CWD ?? process.cwd());
|
|
263
|
+
process.env.EPICSHOP_CONTEXT_CWD = workshopRoot;
|
|
264
|
+
const { silent = false, dryRun = false } = options;
|
|
265
|
+
const fail = (message, { warnings = [] } = {}) => {
|
|
266
|
+
if (!silent) {
|
|
267
|
+
console.log(chalk.bold.cyan('\n🛠️ Admin: Set videos\n'));
|
|
268
|
+
console.log(chalk.red(`❌ ${message}`));
|
|
269
|
+
if (warnings.length > 0) {
|
|
270
|
+
console.log();
|
|
271
|
+
for (const warning of warnings) {
|
|
272
|
+
console.log(chalk.yellow(`⚠️ ${warning}`));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
console.log();
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
...createFailureResult(message, { dryRun }),
|
|
279
|
+
warnings,
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
const packageJsonPath = path.join(workshopRoot, 'package.json');
|
|
283
|
+
let packageJson;
|
|
284
|
+
try {
|
|
285
|
+
const raw = await fs.readFile(packageJsonPath, 'utf8');
|
|
286
|
+
packageJson = JSON.parse(raw);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
return fail(`Failed to read/parse package.json: ${getErrorMessage(error)}`);
|
|
290
|
+
}
|
|
291
|
+
const product = packageJson && typeof packageJson === 'object'
|
|
292
|
+
? packageJson.epicshop?.product
|
|
293
|
+
: null;
|
|
294
|
+
const productHost = product &&
|
|
295
|
+
typeof product === 'object' &&
|
|
296
|
+
typeof product.host === 'string'
|
|
297
|
+
? product.host.trim()
|
|
298
|
+
: '';
|
|
299
|
+
const productSlug = product &&
|
|
300
|
+
typeof product === 'object' &&
|
|
301
|
+
typeof product.slug === 'string'
|
|
302
|
+
? product.slug.trim()
|
|
303
|
+
: '';
|
|
304
|
+
if (!productHost) {
|
|
305
|
+
return fail('Missing `epicshop.product.host` in package.json (required for set-videos)');
|
|
306
|
+
}
|
|
307
|
+
if (/^https?:\/\//i.test(productHost) || productHost.includes('/')) {
|
|
308
|
+
return fail('`epicshop.product.host` should be a host only (for example: "www.epicweb.dev")');
|
|
309
|
+
}
|
|
310
|
+
if (!productSlug) {
|
|
311
|
+
return fail('Missing `epicshop.product.slug` in package.json (required for set-videos)');
|
|
312
|
+
}
|
|
313
|
+
const { files, errors, warnings } = await collectOrderedVideoFiles({
|
|
314
|
+
workshopRoot,
|
|
315
|
+
});
|
|
316
|
+
if (errors.length > 0) {
|
|
317
|
+
const message = `Cannot set videos because workshop structure is invalid:\n- ${errors.join('\n- ')}`;
|
|
318
|
+
return fail(message, {
|
|
319
|
+
warnings,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
const normalizeLessonSlug = (value) => stripEpicAiSlugSuffix(value.trim());
|
|
323
|
+
const remoteResult = await fetchRemoteWorkshopLessons({
|
|
324
|
+
productHost,
|
|
325
|
+
workshopSlug: productSlug,
|
|
326
|
+
normalizeLessonSlug,
|
|
327
|
+
normalizeSectionSlug: normalizeLessonSlug,
|
|
328
|
+
requireNonEmptyLessonSlug: true,
|
|
329
|
+
});
|
|
330
|
+
if (remoteResult.status === 'error') {
|
|
331
|
+
return fail(remoteResult.message, {
|
|
332
|
+
warnings,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
const remoteLessons = remoteResult.lessons;
|
|
336
|
+
if (remoteLessons.length === 0) {
|
|
337
|
+
return fail('Product API returned no lessons. Is the workshop published on the product site?', {
|
|
338
|
+
warnings,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
const { plans: fileLessonSlotPlans, requiredLessonSlots } = buildFileLessonSlotPlans(files);
|
|
342
|
+
if (remoteLessons.length < requiredLessonSlots) {
|
|
343
|
+
const assignedPairs = fileLessonSlotPlans
|
|
344
|
+
.filter((plan) => plan.lessonSlotIndex < remoteLessons.length)
|
|
345
|
+
.map((plan) => {
|
|
346
|
+
const lesson = remoteLessons[plan.lessonSlotIndex];
|
|
347
|
+
if (!lesson)
|
|
348
|
+
return `${plan.file.relativePath} -> (no lesson)`;
|
|
349
|
+
const lessonUrl = formatProductLessonUrl({
|
|
350
|
+
productHost,
|
|
351
|
+
productSlug,
|
|
352
|
+
lessonSlug: lesson.slug,
|
|
353
|
+
sectionSlug: lesson.sectionSlug,
|
|
354
|
+
});
|
|
355
|
+
return `${plan.file.relativePath} -> ${lessonUrl}`;
|
|
356
|
+
});
|
|
357
|
+
const unassignedLocalFiles = fileLessonSlotPlans
|
|
358
|
+
.filter((plan) => plan.lessonSlotIndex >= remoteLessons.length)
|
|
359
|
+
.map((plan) => `${plan.file.relativePath} (lesson slot ${plan.lessonSlotIndex + 1})`);
|
|
360
|
+
const remoteLessonsInOrder = remoteLessons.map((lesson) => {
|
|
361
|
+
const lessonPath = lesson.sectionSlug
|
|
362
|
+
? `${lesson.sectionSlug}/${lesson.slug}`
|
|
363
|
+
: lesson.slug;
|
|
364
|
+
const lessonUrl = formatProductLessonUrl({
|
|
365
|
+
productHost,
|
|
366
|
+
productSlug,
|
|
367
|
+
lessonSlug: lesson.slug,
|
|
368
|
+
sectionSlug: lesson.sectionSlug,
|
|
369
|
+
});
|
|
370
|
+
return `${lessonPath} -> ${lessonUrl}`;
|
|
371
|
+
});
|
|
372
|
+
const requiredLocalFilesInOrder = fileLessonSlotPlans.map((plan) => `${plan.file.relativePath} (lesson slot ${plan.lessonSlotIndex + 1})`);
|
|
373
|
+
return fail(`Not enough product lessons to map onto workshop files.\nExpected at least ${requiredLessonSlots} lessons, but received ${remoteLessons.length}.\nMissing ${requiredLessonSlots - remoteLessons.length} lesson(s).\nThis mapping uses one lesson slot for workshop intro/wrap-up, exercise intro/summary, and one shared lesson slot per exercise step (applied to both problem + solution files).\n\nAssigned file/video pairs (in order):\n${assignedPairs.length > 0 ? formatNumberedList(assignedPairs) : '(none)'}\n\nUnassigned local files (in order):\n${formatNumberedList(unassignedLocalFiles, {
|
|
374
|
+
startAt: assignedPairs.length + 1,
|
|
375
|
+
})}\n\nProduct lessons returned by API (in order):\n${formatNumberedList(remoteLessonsInOrder)}\n\nRequired local files (in order):\n${formatNumberedList(requiredLocalFilesInOrder)}\n\nHint: verify the product workshop has all expected lessons published and in the same order as the local exercise/step instruction files.`, {
|
|
376
|
+
warnings,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
const plannedEdits = [];
|
|
380
|
+
const editErrors = [];
|
|
381
|
+
for (const plan of fileLessonSlotPlans) {
|
|
382
|
+
const lesson = remoteLessons[plan.lessonSlotIndex];
|
|
383
|
+
if (!lesson)
|
|
384
|
+
continue;
|
|
385
|
+
const targetUrl = formatProductLessonUrl({
|
|
386
|
+
productHost,
|
|
387
|
+
productSlug,
|
|
388
|
+
lessonSlug: lesson.slug,
|
|
389
|
+
sectionSlug: lesson.sectionSlug,
|
|
390
|
+
});
|
|
391
|
+
let currentContent = '';
|
|
392
|
+
try {
|
|
393
|
+
currentContent = await fs.readFile(plan.file.fullPath, 'utf8');
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
editErrors.push(`Failed to read "${plan.file.relativePath}": ${getErrorMessage(error)}`);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const result = upsertTitleEpicVideo({
|
|
400
|
+
content: currentContent,
|
|
401
|
+
url: targetUrl,
|
|
402
|
+
});
|
|
403
|
+
if (result.status === 'error') {
|
|
404
|
+
editErrors.push(`${plan.file.relativePath}: ${result.message}`);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
plannedEdits.push({
|
|
408
|
+
file: plan.file,
|
|
409
|
+
nextContent: result.nextContent,
|
|
410
|
+
outcome: result.outcome,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (editErrors.length > 0) {
|
|
414
|
+
return fail(`Could not update videos for all files:\n- ${editErrors.join('\n- ')}`, {
|
|
415
|
+
warnings,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
if (!dryRun) {
|
|
419
|
+
for (const edit of plannedEdits) {
|
|
420
|
+
if (edit.outcome === 'unchanged')
|
|
421
|
+
continue;
|
|
422
|
+
await fs.writeFile(edit.file.fullPath, edit.nextContent);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const inserted = plannedEdits.filter((edit) => edit.outcome === 'inserted').length;
|
|
426
|
+
const updated = plannedEdits.filter((edit) => edit.outcome === 'updated').length;
|
|
427
|
+
const unchanged = plannedEdits.filter((edit) => edit.outcome === 'unchanged').length;
|
|
428
|
+
if (remoteLessons.length > requiredLessonSlots) {
|
|
429
|
+
const extras = remoteLessons.slice(requiredLessonSlots).map((lesson) => formatProductLessonUrl({
|
|
430
|
+
productHost,
|
|
431
|
+
productSlug,
|
|
432
|
+
lessonSlug: lesson.slug,
|
|
433
|
+
sectionSlug: lesson.sectionSlug,
|
|
434
|
+
}));
|
|
435
|
+
warnings.push(`Product has ${extras.length} extra lesson(s) beyond mapped lesson slots:\n- ${extras.join('\n- ')}`);
|
|
436
|
+
}
|
|
437
|
+
if (!silent) {
|
|
438
|
+
console.log(chalk.bold.cyan('\n🛠️ Admin: Set videos\n'));
|
|
439
|
+
console.log(chalk.green(`✅ ${dryRun ? 'Planned' : 'Updated'} EpicVideo mappings (inserted: ${inserted}, updated: ${updated}, unchanged: ${unchanged})`));
|
|
440
|
+
if (dryRun) {
|
|
441
|
+
console.log(chalk.yellow('🧪 Dry run enabled: no files were modified.'));
|
|
442
|
+
}
|
|
443
|
+
if (warnings.length > 0) {
|
|
444
|
+
console.log();
|
|
445
|
+
for (const warning of warnings) {
|
|
446
|
+
console.log(chalk.yellow(`⚠️ ${warning}`));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
console.log();
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
success: true,
|
|
453
|
+
message: dryRun
|
|
454
|
+
? 'Set videos dry run completed successfully'
|
|
455
|
+
: 'Set videos completed successfully',
|
|
456
|
+
inserted,
|
|
457
|
+
updated,
|
|
458
|
+
unchanged,
|
|
459
|
+
warnings,
|
|
460
|
+
dryRun,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Dirent } from 'node:fs';
|
|
2
|
+
export type RemoteLesson = {
|
|
3
|
+
slug: string;
|
|
4
|
+
sectionSlug: string | null;
|
|
5
|
+
};
|
|
6
|
+
type NormalizeSlug = (value: string) => string;
|
|
7
|
+
export declare function stripEpicAiSlugSuffix(value: string): string;
|
|
8
|
+
export declare function formatProductLessonUrl({ productHost, productSlug, lessonSlug, sectionSlug, }: {
|
|
9
|
+
productHost: string;
|
|
10
|
+
productSlug: string;
|
|
11
|
+
lessonSlug: string;
|
|
12
|
+
sectionSlug: string | null;
|
|
13
|
+
}): string;
|
|
14
|
+
export declare function isDirectory(targetPath: string): Promise<boolean>;
|
|
15
|
+
export declare function resolveMdxFile(dir: string, baseName: 'README' | 'FINISHED'): Promise<string | null>;
|
|
16
|
+
export declare function collectStepDirectories(entries: Array<Dirent>, exerciseRoot: string): Map<number, {
|
|
17
|
+
problems: Array<string>;
|
|
18
|
+
solutions: Array<string>;
|
|
19
|
+
}>;
|
|
20
|
+
export declare function fetchRemoteWorkshopLessons({ productHost, workshopSlug, normalizeLessonSlug, normalizeSectionSlug, requireNonEmptyLessonSlug, }: {
|
|
21
|
+
productHost: string;
|
|
22
|
+
workshopSlug: string;
|
|
23
|
+
normalizeLessonSlug?: NormalizeSlug;
|
|
24
|
+
normalizeSectionSlug?: NormalizeSlug;
|
|
25
|
+
requireNonEmptyLessonSlug?: boolean;
|
|
26
|
+
}): Promise<{
|
|
27
|
+
status: 'success';
|
|
28
|
+
lessons: Array<RemoteLesson>;
|
|
29
|
+
} | {
|
|
30
|
+
status: 'error';
|
|
31
|
+
message: string;
|
|
32
|
+
}>;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getAuthInfo } from '@epic-web/workshop-utils/db.server';
|
|
4
|
+
import { getErrorMessage } from '@epic-web/workshop-utils/utils';
|
|
5
|
+
import { pathExists } from "../../utils/filesystem.js";
|
|
6
|
+
export function stripEpicAiSlugSuffix(value) {
|
|
7
|
+
// EpicAI embeds sometimes include a `~...` suffix in the slug segment.
|
|
8
|
+
return value.replace(/~[^ ]*$/, '');
|
|
9
|
+
}
|
|
10
|
+
export function formatProductLessonUrl({ productHost, productSlug, lessonSlug, sectionSlug, }) {
|
|
11
|
+
// The product site will typically redirect to a section-specific path when needed.
|
|
12
|
+
return sectionSlug
|
|
13
|
+
? `https://${productHost}/workshops/${productSlug}/${sectionSlug}/${lessonSlug}`
|
|
14
|
+
: `https://${productHost}/workshops/${productSlug}/${lessonSlug}`;
|
|
15
|
+
}
|
|
16
|
+
export async function isDirectory(targetPath) {
|
|
17
|
+
try {
|
|
18
|
+
return (await fs.stat(targetPath)).isDirectory();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function resolveMdxFile(dir, baseName) {
|
|
25
|
+
const mdx = path.join(dir, `${baseName}.mdx`);
|
|
26
|
+
if (await pathExists(mdx))
|
|
27
|
+
return mdx;
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
export function collectStepDirectories(entries, exerciseRoot) {
|
|
31
|
+
const stepDirRegex = /^(?<stepNumber>\d+)\.(?<type>problem|solution)(\..*)?$/;
|
|
32
|
+
const stepsByNumber = new Map();
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
if (!entry.isDirectory())
|
|
35
|
+
continue;
|
|
36
|
+
const match = stepDirRegex.exec(entry.name);
|
|
37
|
+
if (!match?.groups)
|
|
38
|
+
continue;
|
|
39
|
+
const stepNumber = Number(match.groups.stepNumber);
|
|
40
|
+
const type = match.groups.type;
|
|
41
|
+
if (!Number.isFinite(stepNumber) || stepNumber <= 0)
|
|
42
|
+
continue;
|
|
43
|
+
const current = stepsByNumber.get(stepNumber) ?? {
|
|
44
|
+
problems: [],
|
|
45
|
+
solutions: [],
|
|
46
|
+
};
|
|
47
|
+
const fullStepDir = path.join(exerciseRoot, entry.name);
|
|
48
|
+
if (type === 'problem')
|
|
49
|
+
current.problems.push(fullStepDir);
|
|
50
|
+
if (type === 'solution')
|
|
51
|
+
current.solutions.push(fullStepDir);
|
|
52
|
+
stepsByNumber.set(stepNumber, current);
|
|
53
|
+
}
|
|
54
|
+
return stepsByNumber;
|
|
55
|
+
}
|
|
56
|
+
export async function fetchRemoteWorkshopLessons({ productHost, workshopSlug, normalizeLessonSlug, normalizeSectionSlug, requireNonEmptyLessonSlug = false, }) {
|
|
57
|
+
const url = `https://${productHost}/api/workshops/${encodeURIComponent(workshopSlug)}`;
|
|
58
|
+
const fetchOnce = async (accessToken) => {
|
|
59
|
+
const timeout = AbortSignal.timeout(15_000);
|
|
60
|
+
const headers = {};
|
|
61
|
+
if (accessToken)
|
|
62
|
+
headers.authorization = `Bearer ${accessToken}`;
|
|
63
|
+
return fetch(url, { headers, signal: timeout });
|
|
64
|
+
};
|
|
65
|
+
let response = null;
|
|
66
|
+
try {
|
|
67
|
+
response = await fetchOnce();
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
return {
|
|
71
|
+
status: 'error',
|
|
72
|
+
message: `Failed to fetch product workshop data: ${getErrorMessage(error)}`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (response.status === 401 || response.status === 403) {
|
|
76
|
+
const authInfo = await getAuthInfo({ productHost }).catch(() => null);
|
|
77
|
+
const accessToken = authInfo?.tokenSet?.access_token;
|
|
78
|
+
if (accessToken) {
|
|
79
|
+
try {
|
|
80
|
+
response = await fetchOnce(accessToken);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
return {
|
|
84
|
+
status: 'error',
|
|
85
|
+
message: `Failed to fetch product workshop data (after auth): ${getErrorMessage(error)}`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
const body = await response.text().catch(() => '');
|
|
92
|
+
const hint = response.status === 401 || response.status === 403
|
|
93
|
+
? ` (try: npx epicshop auth login ${productHost.replace(/^www\./, '')})`
|
|
94
|
+
: response.status === 404
|
|
95
|
+
? ` (check epicshop.product.host + epicshop.product.slug)`
|
|
96
|
+
: '';
|
|
97
|
+
return {
|
|
98
|
+
status: 'error',
|
|
99
|
+
message: `Product API request failed: ${response.status} ${response.statusText}${hint}${body ? `\n${body}` : ''}`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
let data;
|
|
103
|
+
try {
|
|
104
|
+
data = await response.json();
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return {
|
|
108
|
+
status: 'error',
|
|
109
|
+
message: `Product API response was not valid JSON: ${getErrorMessage(error)}`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const resources = data && typeof data === 'object' && 'resources' in data
|
|
113
|
+
? data.resources
|
|
114
|
+
: null;
|
|
115
|
+
if (!Array.isArray(resources)) {
|
|
116
|
+
return {
|
|
117
|
+
status: 'error',
|
|
118
|
+
message: `Product API response did not include an array "resources" field`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const applyNormalizer = (value, normalizer) => {
|
|
122
|
+
return normalizer ? normalizer(value) : value;
|
|
123
|
+
};
|
|
124
|
+
const lessons = [];
|
|
125
|
+
for (const resource of resources) {
|
|
126
|
+
if (!resource || typeof resource !== 'object')
|
|
127
|
+
continue;
|
|
128
|
+
const item = resource;
|
|
129
|
+
if (item._type === 'lesson') {
|
|
130
|
+
const slug = item.slug;
|
|
131
|
+
if (typeof slug === 'string') {
|
|
132
|
+
const normalizedSlug = applyNormalizer(slug, normalizeLessonSlug);
|
|
133
|
+
if (requireNonEmptyLessonSlug && normalizedSlug.trim().length === 0) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
lessons.push({ slug: normalizedSlug, sectionSlug: null });
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (item._type === 'section') {
|
|
141
|
+
let sectionSlug = null;
|
|
142
|
+
if (typeof item.slug === 'string') {
|
|
143
|
+
const normalizedSectionSlug = applyNormalizer(item.slug, normalizeSectionSlug);
|
|
144
|
+
const trimmedSectionSlug = normalizedSectionSlug.trim();
|
|
145
|
+
if (trimmedSectionSlug.length > 0) {
|
|
146
|
+
sectionSlug = trimmedSectionSlug;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const sectionLessons = item.lessons;
|
|
150
|
+
if (!Array.isArray(sectionLessons))
|
|
151
|
+
continue;
|
|
152
|
+
for (const lesson of sectionLessons) {
|
|
153
|
+
if (!lesson || typeof lesson !== 'object')
|
|
154
|
+
continue;
|
|
155
|
+
const lessonItem = lesson;
|
|
156
|
+
const slug = lessonItem.slug;
|
|
157
|
+
if (typeof slug === 'string') {
|
|
158
|
+
const normalizedSlug = applyNormalizer(slug, normalizeLessonSlug);
|
|
159
|
+
if (requireNonEmptyLessonSlug && normalizedSlug.trim().length === 0) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
lessons.push({ slug: normalizedSlug, sectionSlug });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { status: 'success', lessons };
|
|
168
|
+
}
|
package/dist/commands/admin.d.ts
CHANGED
package/dist/commands/admin.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "epicshop",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.85.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"build:watch": "nx watch --projects=epicshop -- nx run \\$NX_PROJECT_NAME:build"
|
|
106
106
|
},
|
|
107
107
|
"dependencies": {
|
|
108
|
-
"@epic-web/workshop-utils": "6.
|
|
108
|
+
"@epic-web/workshop-utils": "6.85.0",
|
|
109
109
|
"@inquirer/prompts": "^8.2.0",
|
|
110
110
|
"@sentry/node": "^10.38.0",
|
|
111
111
|
"chalk": "^5.6.2",
|