@@ -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
6791function normalizeHost ( host : string ) {
6892 return host . toLowerCase ( ) . replace ( / ^ w w w \. / , '' )
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
117164function 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 ( / ^ w w w \. / , '' ) } )`
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
292460async 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