Skip to content

Commit df120c7

Browse files
committed
fix(cli): make the CLI handle tutorials better
1 parent 7bf723e commit df120c7

4 files changed

Lines changed: 488 additions & 15 deletions

File tree

packages/workshop-cli/src/commands/admin/launch-readiness.test.ts

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,12 @@ async function createWorkshopFixture({
3535
productHost = 'www.epicweb.dev',
3636
productSlug = 'test-workshop',
3737
includeProductSlug = true,
38+
productPath = 'workshops',
3839
}: {
3940
productHost?: string
4041
productSlug?: string
4142
includeProductSlug?: boolean
43+
productPath?: 'workshops' | 'tutorials'
4244
} = {}) {
4345
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'epicshop-launch-'))
4446

@@ -58,30 +60,30 @@ async function createWorkshopFixture({
5860
// Workshop intro + wrap-up
5961
await writeFile(
6062
path.join(root, 'exercises', 'README.mdx'),
61-
`# Workshop Intro\n\n<EpicVideo url="https://${productHost}/workshops/${productSlug}/workshop-intro" />\n`,
63+
`# Workshop Intro\n\n<EpicVideo url="https://${productHost}/${productPath}/${productSlug}/workshop-intro" />\n`,
6264
)
6365
await writeFile(
6466
path.join(root, 'exercises', 'FINISHED.mdx'),
65-
`# Workshop Wrap Up\n\n<EpicVideo url="https://${productHost}/workshops/${productSlug}/workshop-wrap-up" />\n`,
67+
`# Workshop Wrap Up\n\n<EpicVideo url="https://${productHost}/${productPath}/${productSlug}/workshop-wrap-up" />\n`,
6668
)
6769

6870
// One exercise with one step
6971
const exRoot = path.join(root, 'exercises', '01.first-exercise')
7072
await writeFile(
7173
path.join(exRoot, 'README.mdx'),
72-
`# Exercise Intro\n\n<EpicVideo url="https://${productHost}/workshops/${productSlug}/exercise-intro" />\n`,
74+
`# Exercise Intro\n\n<EpicVideo url="https://${productHost}/${productPath}/${productSlug}/exercise-intro" />\n`,
7375
)
7476
await writeFile(
7577
path.join(exRoot, 'FINISHED.mdx'),
76-
`# Exercise Summary\n\n<EpicVideo url="https://${productHost}/workshops/${productSlug}/exercise-summary" />\n`,
78+
`# Exercise Summary\n\n<EpicVideo url="https://${productHost}/${productPath}/${productSlug}/exercise-summary" />\n`,
7779
)
7880
await writeFile(
7981
path.join(exRoot, '01.problem', 'README.mdx'),
80-
`# Step Problem\n\n<EpicVideo url="https://${productHost}/workshops/${productSlug}/step-problem" />\n`,
82+
`# Step Problem\n\n<EpicVideo url="https://${productHost}/${productPath}/${productSlug}/step-problem" />\n`,
8183
)
8284
await writeFile(
8385
path.join(exRoot, '01.solution', 'README.mdx'),
84-
`# Step Solution\n\n<EpicVideo url="https://${productHost}/workshops/${productSlug}/step-solution" />\n`,
86+
`# Step Solution\n\n<EpicVideo url="https://${productHost}/${productPath}/${productSlug}/step-solution" />\n`,
8587
)
8688

8789
return {
@@ -303,3 +305,51 @@ test('fails when an EpicVideo url does not return 200 to HEAD', async () => {
303305
}),
304306
).resolves.toEqual(expect.objectContaining({ success: false }))
305307
})
308+
309+
test('accepts /tutorials embed paths and reports tutorial urls in remote mismatch output', async () => {
310+
const productHost = 'www.epicweb.dev'
311+
const productSlug = 'test-tutorial~abc123'
312+
await using workshop = await createWorkshopFixture({
313+
productHost,
314+
productSlug,
315+
productPath: 'tutorials',
316+
})
317+
318+
vi.stubGlobal(
319+
'fetch',
320+
vi.fn(async () => {
321+
return new Response(
322+
JSON.stringify({
323+
moduleType: 'tutorial',
324+
resources: [
325+
{ _type: 'lesson', _id: '1', slug: 'workshop-intro' },
326+
{
327+
_type: 'section',
328+
_id: 's1',
329+
slug: 'functions-section',
330+
lessons: [{ _type: 'lesson', _id: '2', slug: 'missing-lesson' }],
331+
},
332+
],
333+
}),
334+
{ status: 200 },
335+
)
336+
}),
337+
)
338+
339+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
340+
const result = await launchReadiness({
341+
workshopRoot: workshop.root,
342+
silent: false,
343+
skipRemote: false,
344+
skipHead: true,
345+
})
346+
347+
expect(result.success).toBe(false)
348+
const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n')
349+
expect(output).toContain(
350+
`https://${productHost}/tutorials/${productSlug}/functions-section/missing-lesson`,
351+
)
352+
expect(output).not.toContain(
353+
'/workshops/test-tutorial~abc123/functions-section/missing-lesson',
354+
)
355+
})

packages/workshop-cli/src/commands/admin/launch-readiness.ts

Lines changed: 178 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,38 @@ type ContentCheckFile = {
6464
relativePath: string
6565
}
6666

67+
<<<<<<< Updated upstream
68+
<<<<<<< Updated upstream
69+
=======
70+
=======
71+
>>>>>>> Stashed changes
72+
type ProductModuleType = 'workshop' | 'tutorial'
73+
74+
function stripEpicAiSlugSuffix(value: string) {
75+
// EpicAI embeds sometimes include a `~...` suffix in the slug segment.
76+
return value.replace(/~[^ ]*$/, '')
77+
}
78+
79+
function isProductLessonPathSegment(segment: string | undefined) {
80+
return segment === 'workshops' || segment === 'tutorials'
81+
}
82+
83+
function getProductModulePathSegment(moduleType: ProductModuleType) {
84+
return moduleType === 'tutorial' ? 'tutorials' : 'workshops'
85+
}
86+
87+
<<<<<<< Updated upstream
88+
>>>>>>> Stashed changes
89+
=======
90+
>>>>>>> Stashed changes
6791
function normalizeHost(host: string) {
6892
return host.toLowerCase().replace(/^www\./, '')
6993
}
7094

71-
function parseEpicWorkshopSlugFromEmbedUrl(urlString: string): string | null {
95+
function parseEpicProductSlugFromEmbedUrl(urlString: string): string | null {
7296
const parseSegments = (segments: Array<string>) => {
73-
// Expected: /workshops/<workshopSlug>/...
74-
if (segments[0] !== 'workshops') return null
97+
// Expected: /workshops/<slug>/... or /tutorials/<slug>/...
98+
if (!isProductLessonPathSegment(segments[0])) return null
7599
const workshopSlug = segments[1] ?? null
76100
return workshopSlug ? stripEpicAiSlugSuffix(workshopSlug) : null
77101
}
@@ -114,6 +138,29 @@ function parseEpicLessonSlugFromEmbedUrl(urlString: string): string | null {
114138
}
115139
}
116140

141+
<<<<<<< Updated upstream
142+
=======
143+
function formatProductLessonUrl({
144+
productHost,
145+
productSlug,
146+
moduleType,
147+
lessonSlug,
148+
sectionSlug,
149+
}: {
150+
productHost: string
151+
productSlug: string
152+
moduleType: ProductModuleType
153+
lessonSlug: string
154+
sectionSlug: string | null
155+
}) {
156+
const productPath = getProductModulePathSegment(moduleType)
157+
// The product site will typically redirect to a section-specific path when needed.
158+
return sectionSlug
159+
? `https://${productHost}/${productPath}/${productSlug}/${sectionSlug}/${lessonSlug}`
160+
: `https://${productHost}/${productPath}/${productSlug}/${lessonSlug}`
161+
}
162+
163+
>>>>>>> Stashed changes
117164
function formatIssue(issue: Issue, workshopRoot: string) {
118165
const icon = issue.level === 'error' ? chalk.red('❌') : chalk.yellow('⚠️ ')
119166
const filePart = issue.file
@@ -289,6 +336,127 @@ async function buildExpectedFiles({
289336
return { files, contentFiles, issues }
290337
}
291338

339+
<<<<<<< Updated upstream
340+
=======
341+
async function fetchRemoteWorkshopLessonSlugs({
342+
productHost,
343+
workshopSlug,
344+
}: {
345+
productHost: string
346+
workshopSlug: string
347+
}): Promise<
348+
| {
349+
status: 'success'
350+
lessons: Array<{ slug: string; sectionSlug: string | null }>
351+
moduleType: ProductModuleType
352+
}
353+
| { status: 'error'; message: string }
354+
> {
355+
const url = `https://${productHost}/api/workshops/${encodeURIComponent(workshopSlug)}`
356+
357+
const fetchOnce = async (accessToken?: string) => {
358+
const timeout = AbortSignal.timeout(15_000)
359+
const headers: Record<string, string> = {}
360+
if (accessToken) headers.authorization = `Bearer ${accessToken}`
361+
return fetch(url, { headers, signal: timeout })
362+
}
363+
364+
let response: Response | null = null
365+
try {
366+
response = await fetchOnce()
367+
} catch (error) {
368+
return {
369+
status: 'error',
370+
message: `Failed to fetch product workshop data: ${getErrorMessage(error)}`,
371+
}
372+
}
373+
374+
if (response.status === 401 || response.status === 403) {
375+
const authInfo = await getAuthInfo({ productHost }).catch(() => null)
376+
const accessToken = authInfo?.tokenSet?.access_token
377+
if (accessToken) {
378+
try {
379+
response = await fetchOnce(accessToken)
380+
} catch (error) {
381+
return {
382+
status: 'error',
383+
message: `Failed to fetch product workshop data (after auth): ${getErrorMessage(
384+
error,
385+
)}`,
386+
}
387+
}
388+
}
389+
}
390+
391+
if (!response.ok) {
392+
const body = await response.text().catch(() => '')
393+
const hint =
394+
response.status === 401 || response.status === 403
395+
? ` (try: npx epicshop auth login ${productHost.replace(/^www\./, '')})`
396+
: response.status === 404
397+
? ` (check epicshop.product.host + epicshop.product.slug)`
398+
: ''
399+
return {
400+
status: 'error',
401+
message: `Product API request failed: ${response.status} ${response.statusText}${hint}${
402+
body ? `\n${body}` : ''
403+
}`,
404+
}
405+
}
406+
407+
let data: any
408+
try {
409+
data = await response.json()
410+
} catch (error) {
411+
return {
412+
status: 'error',
413+
message: `Product API response was not valid JSON: ${getErrorMessage(error)}`,
414+
}
415+
}
416+
417+
const resources = data?.resources
418+
const moduleType: ProductModuleType =
419+
data?.moduleType === 'tutorial' ? 'tutorial' : 'workshop'
420+
if (!Array.isArray(resources)) {
421+
return {
422+
status: 'error',
423+
message: `Product API response did not include an array "resources" field`,
424+
}
425+
}
426+
427+
const lessons: Array<{ slug: string; sectionSlug: string | null }> = []
428+
for (const resource of resources) {
429+
if (!resource || typeof resource !== 'object') continue
430+
const r = resource as Record<string, unknown>
431+
432+
if (r._type === 'lesson') {
433+
const slug = r.slug
434+
if (typeof slug === 'string') lessons.push({ slug, sectionSlug: null })
435+
continue
436+
}
437+
438+
if (r._type === 'section') {
439+
const sectionSlug =
440+
typeof r.slug === 'string' && r.slug.trim().length > 0
441+
? r.slug.trim()
442+
: null
443+
const sectionLessons = r.lessons
444+
if (!Array.isArray(sectionLessons)) continue
445+
for (const lesson of sectionLessons) {
446+
if (!lesson || typeof lesson !== 'object') continue
447+
const l = lesson as Record<string, unknown>
448+
const slug = l.slug
449+
if (typeof slug === 'string') {
450+
lessons.push({ slug, sectionSlug })
451+
}
452+
}
453+
}
454+
}
455+
456+
return { status: 'success', lessons, moduleType }
457+
}
458+
459+
>>>>>>> Stashed changes
292460
async function checkMinContentLength({
293461
fullPath,
294462
minChars,
@@ -801,14 +969,14 @@ export async function launchReadiness(
801969
}
802970

803971
const segments = url.pathname.split('/').filter(Boolean)
804-
// Expected: /workshops/<workshopSlug>/...
805-
if (segments[0] !== 'workshops') {
972+
// Expected: /workshops/<workshopSlug>/... or /tutorials/<tutorialSlug>/...
973+
if (!isProductLessonPathSegment(segments[0])) {
806974
for (const file of usedBy) {
807975
issues.push({
808976
level: 'warning',
809977
code: 'epic-video-url-unexpected-path',
810978
message:
811-
'EpicVideo url path does not start with /workshops/... (this may break progress tracking)',
979+
'EpicVideo url path does not start with /workshops/... or /tutorials/... (this may break progress tracking)',
812980
file,
813981
})
814982
}
@@ -839,7 +1007,7 @@ export async function launchReadiness(
8391007
for (const embedUrl of embedOccurrences.keys()) {
8401008
const lessonSlug = parseEpicLessonSlugFromEmbedUrl(embedUrl)
8411009
if (!lessonSlug) continue
842-
const workshopSlug = parseEpicWorkshopSlugFromEmbedUrl(embedUrl)
1010+
const workshopSlug = parseEpicProductSlugFromEmbedUrl(embedUrl)
8431011
if (!workshopSlug || workshopSlug !== productSlug) continue
8441012
try {
8451013
const url = new URL(embedUrl)
@@ -863,6 +1031,7 @@ export async function launchReadiness(
8631031
message: remote.message,
8641032
})
8651033
} else {
1034+
const remoteModuleType = remote.moduleType
8661035
const remoteLessons = remote.lessons
8671036
.map((l) => ({
8681037
slug: stripEpicAiSlugSuffix(l.slug),
@@ -903,6 +1072,7 @@ export async function launchReadiness(
9031072
return `- ${slug}: ${formatProductLessonUrl({
9041073
productHost,
9051074
productSlug,
1075+
moduleType: remoteModuleType,
9061076
lessonSlug: slug,
9071077
sectionSlug: remoteLesson?.sectionSlug ?? null,
9081078
})}`
@@ -919,7 +1089,7 @@ export async function launchReadiness(
9191089
for (const [embedUrl, usedBy] of embedOccurrences.entries()) {
9201090
const lessonSlug = parseEpicLessonSlugFromEmbedUrl(embedUrl)
9211091
if (!lessonSlug) continue
922-
const workshopSlug = parseEpicWorkshopSlugFromEmbedUrl(embedUrl)
1092+
const workshopSlug = parseEpicProductSlugFromEmbedUrl(embedUrl)
9231093
if (!workshopSlug || workshopSlug !== productSlug) continue
9241094
try {
9251095
const url = new URL(embedUrl)

0 commit comments

Comments
 (0)