@learnpack/learnpack 5.0.272 → 5.0.274
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 +409 -409
- package/lib/commands/audit.js +15 -15
- package/lib/commands/breakToken.js +19 -19
- package/lib/commands/clean.js +3 -3
- package/lib/commands/init.js +41 -41
- package/lib/commands/logout.js +3 -3
- package/lib/commands/serve.js +32 -17
- package/lib/creatorDist/assets/{index-C1pv1wUb.js → index-BfLyIQVh.js} +10351 -10227
- package/lib/creatorDist/assets/{index-B4khtb0r.css → index-C39zeF3W.css} +3 -3
- package/lib/creatorDist/index.html +2 -2
- package/lib/managers/config/index.js +77 -77
- package/lib/utils/api.js +1 -0
- package/lib/utils/creatorUtilities.js +14 -14
- package/package.json +1 -1
- package/src/commands/audit.ts +487 -487
- package/src/commands/breakToken.ts +67 -67
- package/src/commands/clean.ts +30 -30
- package/src/commands/init.ts +650 -650
- package/src/commands/logout.ts +38 -38
- package/src/commands/publish.ts +522 -522
- package/src/commands/serve.ts +38 -28
- package/src/commands/start.ts +333 -333
- package/src/commands/translate.ts +123 -123
- package/src/creator/README.md +54 -54
- package/src/creator/eslint.config.js +28 -28
- package/src/creator/src/components/syllabus/ContentIndex.tsx +1 -1
- package/src/creator/src/i18n.ts +28 -28
- package/src/creator/src/index.css +217 -217
- package/src/creator/src/locales/en.json +1 -0
- package/src/creator/src/locales/es.json +1 -0
- package/src/creator/src/utils/configTypes.ts +122 -122
- package/src/creator/src/utils/constants.ts +13 -13
- package/src/creator/src/utils/creatorUtils.ts +46 -46
- package/src/creator/src/utils/eventBus.ts +2 -2
- package/src/creator/src/utils/lib.ts +468 -468
- package/src/creator/src/utils/rigo.ts +26 -26
- package/src/creator/src/utils/socket.ts +61 -61
- package/src/creator/src/utils/store.ts +222 -222
- package/src/creator/src/vite-env.d.ts +1 -1
- package/src/creator/vite.config.ts +13 -13
- package/src/creatorDist/assets/{index-C1pv1wUb.js → index-BfLyIQVh.js} +10351 -10227
- package/src/creatorDist/assets/{index-B4khtb0r.css → index-C39zeF3W.css} +3 -3
- package/src/creatorDist/index.html +2 -2
- package/src/managers/config/defaults.ts +49 -49
- package/src/managers/config/exercise.ts +364 -364
- package/src/managers/config/index.ts +775 -775
- package/src/managers/file.ts +236 -236
- package/src/managers/server/routes.ts +554 -554
- package/src/managers/session.ts +182 -182
- package/src/managers/telemetry.ts +188 -188
- package/src/models/action.ts +13 -13
- package/src/models/config-manager.ts +28 -28
- package/src/models/config.ts +106 -106
- package/src/models/creator.ts +40 -40
- package/src/models/exercise-obj.ts +30 -30
- package/src/models/session.ts +39 -39
- package/src/models/socket.ts +61 -61
- package/src/models/status.ts +16 -16
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +435 -414
- package/src/ui/_app/learnpack.svg +7 -7
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/BaseCommand.ts +56 -56
- package/src/utils/api.ts +31 -30
- package/src/utils/audit.ts +392 -392
- package/src/utils/checkNotInstalled.ts +267 -267
- package/src/utils/configBuilder.ts +82 -82
- package/src/utils/convertCreds.js +34 -34
- package/src/utils/creatorUtilities.ts +504 -504
- package/src/utils/incrementVersion.js +74 -74
- package/src/utils/misc.ts +58 -58
- package/src/utils/rigoActions.ts +500 -500
- package/src/utils/sidebarGenerator.ts +195 -195
- package/src/utils/templates/isolated/exercises/01-hello-world/README.es.md +26 -26
- package/src/utils/templates/isolated/exercises/01-hello-world/README.md +26 -26
package/src/commands/init.ts
CHANGED
@@ -1,650 +1,650 @@
|
|
1
|
-
import { flags } from "@oclif/command"
|
2
|
-
import BaseCommand from "../utils/BaseCommand"
|
3
|
-
import ora from "ora"
|
4
|
-
|
5
|
-
// eslint-disable-next-line
|
6
|
-
import * as fs from "fs-extra"
|
7
|
-
import * as prompts from "prompts"
|
8
|
-
import cli from "cli-ux"
|
9
|
-
import * as eta from "eta"
|
10
|
-
|
11
|
-
import api from "../utils/api"
|
12
|
-
import Console from "../utils/console"
|
13
|
-
import { ValidationError } from "../utils/errors"
|
14
|
-
|
15
|
-
import * as path from "path"
|
16
|
-
import {
|
17
|
-
// hasCreatorPermission,
|
18
|
-
generateImage,
|
19
|
-
downloadImage,
|
20
|
-
interactiveCreation,
|
21
|
-
createCodeFile,
|
22
|
-
readmeCreator,
|
23
|
-
createPreviewReadme,
|
24
|
-
makeReadmeReadable,
|
25
|
-
isValidRigoToken,
|
26
|
-
} from "../utils/rigoActions"
|
27
|
-
import { getConsumable } from "../utils/api"
|
28
|
-
import {
|
29
|
-
checkReadability,
|
30
|
-
PackageInfo,
|
31
|
-
getExInfo,
|
32
|
-
extractImagesFromMarkdown,
|
33
|
-
getFilenameFromUrl,
|
34
|
-
makePackageInfo,
|
35
|
-
estimateDuration,
|
36
|
-
appendContentIndex,
|
37
|
-
appendAIRules,
|
38
|
-
slugify,
|
39
|
-
} from "../utils/creatorUtilities"
|
40
|
-
import SessionManager from "../managers/session"
|
41
|
-
|
42
|
-
const durationByKind: Record<string, number> = {
|
43
|
-
code: 5,
|
44
|
-
quiz: 3,
|
45
|
-
read: 2,
|
46
|
-
}
|
47
|
-
|
48
|
-
function estimateActivities(estimatedDuration: number) {
|
49
|
-
const result: Record<string, number> = {}
|
50
|
-
|
51
|
-
for (const kind of Object.keys(durationByKind)) {
|
52
|
-
result[kind] = Math.floor(estimatedDuration / durationByKind[kind])
|
53
|
-
}
|
54
|
-
|
55
|
-
return result
|
56
|
-
}
|
57
|
-
|
58
|
-
const PARAMS = {
|
59
|
-
expected_grade_level: "6",
|
60
|
-
max_fkgl: 8,
|
61
|
-
max_words: 200,
|
62
|
-
max_rewrite_attempts: 3,
|
63
|
-
max_title_length: 50,
|
64
|
-
}
|
65
|
-
|
66
|
-
const whichTargetAudienceAndEstimatedDuration = async () => {
|
67
|
-
const res = await prompts([
|
68
|
-
{
|
69
|
-
type: "text",
|
70
|
-
name: "targetAudience",
|
71
|
-
message: "What is the target audience for this tutorial?",
|
72
|
-
},
|
73
|
-
{
|
74
|
-
type: "number",
|
75
|
-
name: "estimatedDuration",
|
76
|
-
message: "What is the estimated duration for this tutorial in minutes?",
|
77
|
-
},
|
78
|
-
])
|
79
|
-
return {
|
80
|
-
targetAudience: res.targetAudience,
|
81
|
-
estimatedDuration: res.estimatedDuration,
|
82
|
-
}
|
83
|
-
}
|
84
|
-
|
85
|
-
async function processExercise(
|
86
|
-
rigoToken: string,
|
87
|
-
steps: string[],
|
88
|
-
packageContext: string,
|
89
|
-
exercise: any,
|
90
|
-
exercisesDir: string
|
91
|
-
): Promise<string> {
|
92
|
-
const { exNumber, exTitle, kind, description } = getExInfo(exercise)
|
93
|
-
const exerciseDir = path.join(exercisesDir, `${exNumber}-${exTitle}`)
|
94
|
-
|
95
|
-
const readme = await readmeCreator(
|
96
|
-
rigoToken,
|
97
|
-
{
|
98
|
-
title: `${exNumber} - ${exTitle}`,
|
99
|
-
output_lang: "en",
|
100
|
-
list_of_exercises: steps.join(","),
|
101
|
-
tutorial_description: packageContext,
|
102
|
-
lesson_description: description,
|
103
|
-
kind: kind.toLowerCase(),
|
104
|
-
last_lesson: "",
|
105
|
-
},
|
106
|
-
"learnpack-lesson-writer"
|
107
|
-
)
|
108
|
-
|
109
|
-
const duration = durationByKind[kind.toLowerCase()]
|
110
|
-
let attempts = 0
|
111
|
-
let readability = checkReadability(readme.parsed.content, 200, duration || 1)
|
112
|
-
|
113
|
-
while (
|
114
|
-
readability.fkglResult.fkgl > PARAMS.max_fkgl &&
|
115
|
-
attempts < PARAMS.max_rewrite_attempts
|
116
|
-
) {
|
117
|
-
Console.warning(
|
118
|
-
`The lesson ${exTitle} has as readability score of ${
|
119
|
-
readability.fkglResult.fkgl
|
120
|
-
} . It exceeds the maximum of words per minute. Rewriting it... (Attempt ${
|
121
|
-
attempts + 1
|
122
|
-
})`
|
123
|
-
)
|
124
|
-
|
125
|
-
// eslint-disable-next-line
|
126
|
-
const reducedReadme = await makeReadmeReadable(
|
127
|
-
rigoToken,
|
128
|
-
{
|
129
|
-
lesson: readability.body,
|
130
|
-
number_of_words: readability.minutes.toString(),
|
131
|
-
expected_number_words: PARAMS.max_words.toString(),
|
132
|
-
fkgl_results: JSON.stringify(readability.fkglResult),
|
133
|
-
expected_grade_level: PARAMS.expected_grade_level,
|
134
|
-
},
|
135
|
-
"learnpack-lesson-writer"
|
136
|
-
)
|
137
|
-
|
138
|
-
// console.log("REDUCED README START", reducedReadme, "REDUCED README END")
|
139
|
-
|
140
|
-
if (!reducedReadme) break
|
141
|
-
|
142
|
-
readability = checkReadability(
|
143
|
-
reducedReadme.parsed.content,
|
144
|
-
PARAMS.max_words,
|
145
|
-
duration || 1
|
146
|
-
)
|
147
|
-
|
148
|
-
attempts++
|
149
|
-
}
|
150
|
-
|
151
|
-
Console.success(
|
152
|
-
`After ${attempts} attempts, the lesson ${exTitle} has a readability score of ${
|
153
|
-
readability.fkglResult.fkgl
|
154
|
-
} using FKGL. And it has ${readability.minutes.toFixed(
|
155
|
-
2
|
156
|
-
)} minutes of reading time.`
|
157
|
-
)
|
158
|
-
|
159
|
-
const readmeFilename = "README.md"
|
160
|
-
fs.writeFileSync(
|
161
|
-
path.join(exerciseDir, readmeFilename),
|
162
|
-
readability.newMarkdown
|
163
|
-
)
|
164
|
-
|
165
|
-
if (kind.toLowerCase() === "code") {
|
166
|
-
const codeFile = await createCodeFile(rigoToken, {
|
167
|
-
readme: readability.newMarkdown,
|
168
|
-
tutorial_info: packageContext,
|
169
|
-
})
|
170
|
-
|
171
|
-
fs.writeFileSync(
|
172
|
-
path.join(
|
173
|
-
exerciseDir,
|
174
|
-
`app.${codeFile.parsed.extension.replace(".", "")}`
|
175
|
-
),
|
176
|
-
codeFile.parsed.content
|
177
|
-
)
|
178
|
-
}
|
179
|
-
|
180
|
-
return readability.newMarkdown
|
181
|
-
}
|
182
|
-
|
183
|
-
const initializeInteractiveCreation = async (
|
184
|
-
rigoToken: string,
|
185
|
-
courseInfo: string
|
186
|
-
): Promise<{
|
187
|
-
steps: string[]
|
188
|
-
title: string
|
189
|
-
description: string
|
190
|
-
interactions: string
|
191
|
-
difficulty: string
|
192
|
-
duration: number
|
193
|
-
}> => {
|
194
|
-
let prevInteractions = ""
|
195
|
-
let isReady = false
|
196
|
-
let currentSteps = []
|
197
|
-
let currentTitle = ""
|
198
|
-
let currentDescription = ""
|
199
|
-
let currentDifficulty = ""
|
200
|
-
|
201
|
-
while (!isReady) {
|
202
|
-
const spinner = ora("Thinking...").start()
|
203
|
-
let wholeInfo = courseInfo
|
204
|
-
wholeInfo += `
|
205
|
-
Current title: ${currentTitle}
|
206
|
-
Current description: ${currentDescription}
|
207
|
-
`
|
208
|
-
// eslint-disable-next-line
|
209
|
-
const res = await interactiveCreation(rigoToken, {
|
210
|
-
courseInfo: wholeInfo,
|
211
|
-
prevInteractions: prevInteractions,
|
212
|
-
})
|
213
|
-
spinner.succeed("Done!")
|
214
|
-
currentSteps = res.parsed.listOfSteps
|
215
|
-
isReady = res.parsed.ready
|
216
|
-
if (res.parsed.title && currentTitle !== res.parsed.title) {
|
217
|
-
currentTitle = res.parsed.title
|
218
|
-
}
|
219
|
-
|
220
|
-
if (
|
221
|
-
res.parsed.description &&
|
222
|
-
currentDescription !== res.parsed.description
|
223
|
-
) {
|
224
|
-
currentDescription = res.parsed.description
|
225
|
-
}
|
226
|
-
|
227
|
-
if (res.parsed.difficulty && currentDifficulty !== res.parsed.difficulty) {
|
228
|
-
currentDifficulty = res.parsed.difficulty
|
229
|
-
}
|
230
|
-
|
231
|
-
if (!isReady) {
|
232
|
-
console.log(currentSteps)
|
233
|
-
Console.info(`AI: ${res.parsed.aiMessage}`)
|
234
|
-
prevInteractions += `\nAI: ${res.parsed.aiMessage}`
|
235
|
-
// eslint-disable-next-line
|
236
|
-
const userMessage = await prompts([
|
237
|
-
{
|
238
|
-
type: "text",
|
239
|
-
name: "userMessage",
|
240
|
-
message: "Your message: ",
|
241
|
-
},
|
242
|
-
])
|
243
|
-
prevInteractions += `\nUser: ${userMessage.userMessage}`
|
244
|
-
}
|
245
|
-
}
|
246
|
-
|
247
|
-
const duration = estimateDuration(currentSteps)
|
248
|
-
return {
|
249
|
-
steps: currentSteps,
|
250
|
-
title: currentTitle,
|
251
|
-
description: currentDescription,
|
252
|
-
interactions: prevInteractions,
|
253
|
-
difficulty: currentDifficulty,
|
254
|
-
duration,
|
255
|
-
}
|
256
|
-
}
|
257
|
-
|
258
|
-
process.emitWarning = (warning: string) => {
|
259
|
-
Console.debug("A Warning was emitted by Node.js: ", warning)
|
260
|
-
}
|
261
|
-
|
262
|
-
const handleAILogic = async (tutorialDir: string, packageInfo: PackageInfo) => {
|
263
|
-
fs.removeSync(path.join(tutorialDir, "exercises", "01-hello-world"))
|
264
|
-
|
265
|
-
let sessionPayload = await SessionManager.getPayload()
|
266
|
-
|
267
|
-
const sessionExists = sessionPayload && sessionPayload.rigobot
|
268
|
-
|
269
|
-
const isValidToken =
|
270
|
-
sessionExists && sessionPayload.rigobot.key ?
|
271
|
-
await isValidRigoToken(sessionPayload.rigobot.key) :
|
272
|
-
false
|
273
|
-
|
274
|
-
const isValidBreathecodeToken =
|
275
|
-
sessionExists && sessionPayload.token ?
|
276
|
-
await api.validateToken(sessionPayload.token) :
|
277
|
-
false
|
278
|
-
|
279
|
-
if (!sessionExists || !isValidBreathecodeToken || !isValidToken) {
|
280
|
-
Console.info(
|
281
|
-
"Almost there! First you need to login with 4Geeks.com to use AI Generation tool for creators. You can create a new account here: https://4geeks.com/checkout?plan=4geeks-creator"
|
282
|
-
)
|
283
|
-
try {
|
284
|
-
sessionPayload = await SessionManager.login()
|
285
|
-
} catch (error) {
|
286
|
-
Console.error("Error trying to authenticate")
|
287
|
-
Console.error((error as TypeError).message || (error as string))
|
288
|
-
}
|
289
|
-
}
|
290
|
-
|
291
|
-
const rigoToken = sessionPayload.rigobot.key
|
292
|
-
|
293
|
-
const consumable = await getConsumable(sessionPayload.token, "ai-generation")
|
294
|
-
|
295
|
-
if (consumable.count === 0) {
|
296
|
-
Console.error(
|
297
|
-
"It seems you cannot generate tutorials with AI. Make sure you creator subscription is up to date here: https://4geeks.com/profile/subscriptions. If you believe there is an issue you can always contact support@4geeks.com"
|
298
|
-
)
|
299
|
-
process.exit(1)
|
300
|
-
}
|
301
|
-
|
302
|
-
// const isCreator = await hasCreatorPermission(rigoToken)
|
303
|
-
// if (!isCreator) {
|
304
|
-
// Console.error(
|
305
|
-
// "👀 Oops! You need to be a creator to use our specialized AI. Please contact support"
|
306
|
-
// )
|
307
|
-
// process.exit(1)
|
308
|
-
// }
|
309
|
-
|
310
|
-
Console.success("🎉 Let's begin this learning journey!")
|
311
|
-
|
312
|
-
const { targetAudience, estimatedDuration } =
|
313
|
-
await whichTargetAudienceAndEstimatedDuration()
|
314
|
-
const contentIndex = await appendContentIndex()
|
315
|
-
const airules = await appendAIRules()
|
316
|
-
|
317
|
-
if (airules) {
|
318
|
-
const rulesDir = path.join(tutorialDir, ".learn", "rules")
|
319
|
-
|
320
|
-
// Crear el directorio si no existe
|
321
|
-
fs.mkdirSync(rulesDir, { recursive: true })
|
322
|
-
|
323
|
-
fs.writeFileSync(path.join(rulesDir, "airules.txt"), airules)
|
324
|
-
}
|
325
|
-
|
326
|
-
let packageContext = `
|
327
|
-
\n
|
328
|
-
Title: "${packageInfo.title.us}"
|
329
|
-
Description: "${packageInfo.description.us}"
|
330
|
-
Target Audience: "${targetAudience}"
|
331
|
-
Estimated Duration: "${estimatedDuration} minutes"
|
332
|
-
|
333
|
-
${
|
334
|
-
contentIndex ?
|
335
|
-
`Content Index submitted by the user, use this to guide your creation. Keep in mind that your tutorial should contain these topics:
|
336
|
-
---
|
337
|
-
${contentIndex}
|
338
|
-
---
|
339
|
-
` :
|
340
|
-
""
|
341
|
-
}
|
342
|
-
|
343
|
-
This is the duration for each type of step, use it to estimate the number of steps to create:
|
344
|
-
${Object.entries(durationByKind)
|
345
|
-
.map(([key, value]) => `${key}: ${value} minutes`)
|
346
|
-
.join("\n")}
|
347
|
-
|
348
|
-
|
349
|
-
Within the estimated duration, is possible to have the following activities:
|
350
|
-
Format=
|
351
|
-
Activity: Maximum number of steps for duration
|
352
|
-
|
353
|
-
Estimated activities:
|
354
|
-
${JSON.stringify(estimateActivities(estimatedDuration))}
|
355
|
-
|
356
|
-
You should create a tutorial that is engaging and fun to follow.
|
357
|
-
|
358
|
-
|
359
|
-
${
|
360
|
-
airules ?
|
361
|
-
`
|
362
|
-
This is a list of rules you need to follow when creating the tutorial:
|
363
|
-
${airules}
|
364
|
-
` :
|
365
|
-
""
|
366
|
-
}
|
367
|
-
`
|
368
|
-
|
369
|
-
const { steps, title, description, duration, difficulty } =
|
370
|
-
await initializeInteractiveCreation(rigoToken, packageContext)
|
371
|
-
packageInfo.title.us = title
|
372
|
-
packageInfo.description.us = description
|
373
|
-
packageInfo.duration = duration
|
374
|
-
packageInfo.difficulty = difficulty
|
375
|
-
|
376
|
-
packageContext = `
|
377
|
-
Title: "${title}"
|
378
|
-
Description: "${description}"
|
379
|
-
Target Audience: "${targetAudience}"
|
380
|
-
List of exercises: ${steps.join(", ")}
|
381
|
-
|
382
|
-
AI Rules: ${airules}
|
383
|
-
`
|
384
|
-
const exercisesDir = path.join(tutorialDir, "exercises")
|
385
|
-
fs.ensureDirSync(exercisesDir)
|
386
|
-
|
387
|
-
Console.info("Creating lessons...")
|
388
|
-
for (const [index, exercise] of steps.entries()) {
|
389
|
-
const { exNumber, exTitle } = getExInfo(exercise)
|
390
|
-
const exerciseDir = path.join(exercisesDir, `${exNumber}-${exTitle}`)
|
391
|
-
fs.ensureDirSync(exerciseDir)
|
392
|
-
}
|
393
|
-
|
394
|
-
const exercisePromises = steps.map(exercise =>
|
395
|
-
processExercise(rigoToken, steps, packageContext, exercise, exercisesDir)
|
396
|
-
)
|
397
|
-
|
398
|
-
const readmeContents = await Promise.all(exercisePromises)
|
399
|
-
Console.success("Lessons created! 🎉")
|
400
|
-
|
401
|
-
Console.info("Generating images for the lessons...")
|
402
|
-
let imagesArray: any[] = []
|
403
|
-
|
404
|
-
for (const content of readmeContents) {
|
405
|
-
imagesArray = [...imagesArray, ...extractImagesFromMarkdown(content)]
|
406
|
-
}
|
407
|
-
|
408
|
-
const imagePromises = imagesArray.map(async (image: any) => {
|
409
|
-
try {
|
410
|
-
const filename = getFilenameFromUrl(image.url)
|
411
|
-
const webhookUrl = `${process.env.HOST}/webhooks/${slugify(
|
412
|
-
packageInfo.title.us
|
413
|
-
)}/images/${filename}`
|
414
|
-
|
415
|
-
const imagePath = path.join(tutorialDir, ".learn", "assets", filename)
|
416
|
-
|
417
|
-
const res = await generateImage(rigoToken, {
|
418
|
-
prompt: image.alt,
|
419
|
-
callbackUrl: webhookUrl,
|
420
|
-
})
|
421
|
-
await downloadImage(res.image_url, imagePath)
|
422
|
-
return true
|
423
|
-
} catch {
|
424
|
-
Console.error(`Error downloading image ${image.url}`)
|
425
|
-
|
426
|
-
return false
|
427
|
-
}
|
428
|
-
})
|
429
|
-
await Promise.all(imagePromises)
|
430
|
-
Console.info(
|
431
|
-
"Images generated successfully! 🎉 Your tutorial will be ready soon!"
|
432
|
-
)
|
433
|
-
|
434
|
-
Console.info("Creating preview readme...")
|
435
|
-
|
436
|
-
await createPreviewReadme(tutorialDir, packageInfo, rigoToken, readmeContents)
|
437
|
-
const imagePath = path.join(tutorialDir, "preview.png")
|
438
|
-
|
439
|
-
const res = await generateImage(rigoToken, {
|
440
|
-
prompt:
|
441
|
-
"Generate a preview image for the tutorial. This is all the tutorial information: " +
|
442
|
-
packageContext +
|
443
|
-
"\n Generate only a basic preview image, add the tutorial Title as a text add the top middle, avoid adding any other text elements. Try to generate an image that related with the tutorial content.",
|
444
|
-
callbackUrl: `${process.env.HOST}/webhooks/${slugify(
|
445
|
-
packageInfo.title.us
|
446
|
-
)}/images/preview.png`,
|
447
|
-
})
|
448
|
-
|
449
|
-
// await downloadImage(res.image_url, imagePath)
|
450
|
-
|
451
|
-
return true
|
452
|
-
}
|
453
|
-
|
454
|
-
const getChoices = async (empty: boolean) => {
|
455
|
-
const defaultChoices = {
|
456
|
-
title: "My Interactive Tutorial",
|
457
|
-
description: "",
|
458
|
-
difficulty: "beginner",
|
459
|
-
duration: 5,
|
460
|
-
useAI: "no",
|
461
|
-
grading: "isolated",
|
462
|
-
}
|
463
|
-
|
464
|
-
if (empty) {
|
465
|
-
return defaultChoices
|
466
|
-
}
|
467
|
-
|
468
|
-
const choices = await prompts([
|
469
|
-
{
|
470
|
-
type: "select",
|
471
|
-
name: "grading",
|
472
|
-
message: "How are you going to grade students or yourself?",
|
473
|
-
choices: [
|
474
|
-
{
|
475
|
-
title:
|
476
|
-
"No grading: No feedback or testing whatsoever, similar to a an interactive book.",
|
477
|
-
value: null,
|
478
|
-
},
|
479
|
-
{
|
480
|
-
title: "Isolated: Each step is a new separate exercise",
|
481
|
-
value: "isolated",
|
482
|
-
},
|
483
|
-
{
|
484
|
-
title:
|
485
|
-
"Step by step: Each step builds on top of each other like an incremental tutorial",
|
486
|
-
value: "incremental",
|
487
|
-
},
|
488
|
-
],
|
489
|
-
},
|
490
|
-
{
|
491
|
-
type: "text",
|
492
|
-
|
493
|
-
name: "title",
|
494
|
-
initial: "My Interactive Tutorial",
|
495
|
-
message: "Title for your tutorial? Press enter to leave as it is",
|
496
|
-
validate: (value: string) => {
|
497
|
-
if (value.length > PARAMS.max_title_length) {
|
498
|
-
return `Title must be less than ${PARAMS.max_title_length} characters`
|
499
|
-
}
|
500
|
-
|
501
|
-
return true
|
502
|
-
},
|
503
|
-
},
|
504
|
-
{
|
505
|
-
type: "text",
|
506
|
-
name: "description",
|
507
|
-
initial: "",
|
508
|
-
message: "Description for your tutorial? Press enter to leave blank",
|
509
|
-
},
|
510
|
-
|
511
|
-
{
|
512
|
-
type: "select",
|
513
|
-
name: "useAI",
|
514
|
-
message:
|
515
|
-
"Want a little bit of AI magic to help you? Our AI can craft the tutorial for you",
|
516
|
-
choices: [
|
517
|
-
{
|
518
|
-
title: "Yes, please help me",
|
519
|
-
value: "yes",
|
520
|
-
},
|
521
|
-
{ title: "No, thanks, I prefer to do it manually", value: "no" },
|
522
|
-
],
|
523
|
-
},
|
524
|
-
])
|
525
|
-
|
526
|
-
const completeChoices = {
|
527
|
-
...choices,
|
528
|
-
difficulty: "beginner",
|
529
|
-
duration: 30,
|
530
|
-
}
|
531
|
-
return completeChoices
|
532
|
-
}
|
533
|
-
|
534
|
-
class InitComand extends BaseCommand {
|
535
|
-
static description =
|
536
|
-
"Create a new learning package: Book, Tutorial or Exercise"
|
537
|
-
|
538
|
-
static flags = {
|
539
|
-
...BaseCommand.flags,
|
540
|
-
grading: flags.help({ char: "h" }),
|
541
|
-
}
|
542
|
-
|
543
|
-
async run() {
|
544
|
-
const { flags } = this.parse(InitComand)
|
545
|
-
|
546
|
-
await alreadyInitialized()
|
547
|
-
|
548
|
-
const choices = await getChoices(flags.yes)
|
549
|
-
|
550
|
-
const packageInfo = makePackageInfo(choices)
|
551
|
-
|
552
|
-
const tutorialDir = `./${packageInfo.slug}`
|
553
|
-
fs.ensureDirSync(tutorialDir)
|
554
|
-
|
555
|
-
const templatesDir = path.resolve(
|
556
|
-
__dirname,
|
557
|
-
"../../src/utils/templates/" + (choices.grading || "no-grading")
|
558
|
-
)
|
559
|
-
if (!fs.existsSync(templatesDir))
|
560
|
-
throw ValidationError(`Template ${templatesDir} does not exists`)
|
561
|
-
await fs.copySync(templatesDir, tutorialDir)
|
562
|
-
|
563
|
-
if (choices.useAI === "yes") {
|
564
|
-
await handleAILogic(tutorialDir, packageInfo)
|
565
|
-
}
|
566
|
-
|
567
|
-
const languages = ["en", "es"]
|
568
|
-
|
569
|
-
// Creating README files
|
570
|
-
for (const language of languages) {
|
571
|
-
const readmeFilename = `README${language !== "en" ? `.${language}` : ""}`
|
572
|
-
const readmeTemplatePath = path.resolve(
|
573
|
-
templatesDir,
|
574
|
-
`${readmeFilename}.ejs`
|
575
|
-
)
|
576
|
-
|
577
|
-
if (choices.useAI !== "yes") {
|
578
|
-
const readmeObject = {
|
579
|
-
title: packageInfo.title.us,
|
580
|
-
description: packageInfo.description.us,
|
581
|
-
grading: packageInfo.grading,
|
582
|
-
difficulty: packageInfo.difficulty,
|
583
|
-
duration: packageInfo.duration,
|
584
|
-
}
|
585
|
-
const readmeContent = eta.render(
|
586
|
-
fs.readFileSync(readmeTemplatePath, "utf-8"),
|
587
|
-
readmeObject
|
588
|
-
)
|
589
|
-
fs.writeFileSync(
|
590
|
-
path.join(tutorialDir, `${readmeFilename}.md`),
|
591
|
-
readmeContent
|
592
|
-
)
|
593
|
-
}
|
594
|
-
|
595
|
-
if (fs.existsSync(path.join(tutorialDir, `${readmeFilename}.ejs`)))
|
596
|
-
fs.removeSync(path.join(tutorialDir, `${readmeFilename}.ejs`))
|
597
|
-
}
|
598
|
-
|
599
|
-
cli.action.start("Initializing package")
|
600
|
-
|
601
|
-
if (!fs.existsSync(path.join(tutorialDir, ".gitignore")))
|
602
|
-
fs.copyFile(
|
603
|
-
path.resolve(__dirname, "../../src/utils/templates/gitignore.txt"),
|
604
|
-
path.join(tutorialDir, ".gitignore")
|
605
|
-
)
|
606
|
-
|
607
|
-
fs.writeFileSync(
|
608
|
-
path.join(tutorialDir, "learn.json"),
|
609
|
-
JSON.stringify(packageInfo, null, 2)
|
610
|
-
)
|
611
|
-
|
612
|
-
cli.action.stop()
|
613
|
-
Console.success(`😋 Package initialized successfully in ${tutorialDir}`)
|
614
|
-
Console.help(
|
615
|
-
`Get inside the tutorial with the command: $ cd ${tutorialDir}`
|
616
|
-
)
|
617
|
-
Console.help(
|
618
|
-
`Start the exercises by running the following command on your terminal: $ learnpack start`
|
619
|
-
)
|
620
|
-
process.exit(0)
|
621
|
-
}
|
622
|
-
}
|
623
|
-
|
624
|
-
const alreadyInitialized = () =>
|
625
|
-
new Promise((resolve, reject) => {
|
626
|
-
fs.readdir("./", function (err: any, files: any) {
|
627
|
-
files = files.filter((f: any) =>
|
628
|
-
[".learn", "learn.json", "bc.json", ".breathecode"].includes(f)
|
629
|
-
)
|
630
|
-
if (err) {
|
631
|
-
reject(ValidationError(err.message))
|
632
|
-
throw ValidationError(err.message)
|
633
|
-
} else if (files.length > 0) {
|
634
|
-
reject(
|
635
|
-
ValidationError(
|
636
|
-
"It seems the package is already initialized because we've found the following files: " +
|
637
|
-
files.join(",")
|
638
|
-
)
|
639
|
-
)
|
640
|
-
throw ValidationError(
|
641
|
-
"It seems the package is already initialized because we've found the following files: " +
|
642
|
-
files.join(",")
|
643
|
-
)
|
644
|
-
}
|
645
|
-
|
646
|
-
resolve(false)
|
647
|
-
})
|
648
|
-
})
|
649
|
-
|
650
|
-
export default InitComand
|
1
|
+
import { flags } from "@oclif/command"
|
2
|
+
import BaseCommand from "../utils/BaseCommand"
|
3
|
+
import ora from "ora"
|
4
|
+
|
5
|
+
// eslint-disable-next-line
|
6
|
+
import * as fs from "fs-extra"
|
7
|
+
import * as prompts from "prompts"
|
8
|
+
import cli from "cli-ux"
|
9
|
+
import * as eta from "eta"
|
10
|
+
|
11
|
+
import api from "../utils/api"
|
12
|
+
import Console from "../utils/console"
|
13
|
+
import { ValidationError } from "../utils/errors"
|
14
|
+
|
15
|
+
import * as path from "path"
|
16
|
+
import {
|
17
|
+
// hasCreatorPermission,
|
18
|
+
generateImage,
|
19
|
+
downloadImage,
|
20
|
+
interactiveCreation,
|
21
|
+
createCodeFile,
|
22
|
+
readmeCreator,
|
23
|
+
createPreviewReadme,
|
24
|
+
makeReadmeReadable,
|
25
|
+
isValidRigoToken,
|
26
|
+
} from "../utils/rigoActions"
|
27
|
+
import { getConsumable } from "../utils/api"
|
28
|
+
import {
|
29
|
+
checkReadability,
|
30
|
+
PackageInfo,
|
31
|
+
getExInfo,
|
32
|
+
extractImagesFromMarkdown,
|
33
|
+
getFilenameFromUrl,
|
34
|
+
makePackageInfo,
|
35
|
+
estimateDuration,
|
36
|
+
appendContentIndex,
|
37
|
+
appendAIRules,
|
38
|
+
slugify,
|
39
|
+
} from "../utils/creatorUtilities"
|
40
|
+
import SessionManager from "../managers/session"
|
41
|
+
|
42
|
+
const durationByKind: Record<string, number> = {
|
43
|
+
code: 5,
|
44
|
+
quiz: 3,
|
45
|
+
read: 2,
|
46
|
+
}
|
47
|
+
|
48
|
+
function estimateActivities(estimatedDuration: number) {
|
49
|
+
const result: Record<string, number> = {}
|
50
|
+
|
51
|
+
for (const kind of Object.keys(durationByKind)) {
|
52
|
+
result[kind] = Math.floor(estimatedDuration / durationByKind[kind])
|
53
|
+
}
|
54
|
+
|
55
|
+
return result
|
56
|
+
}
|
57
|
+
|
58
|
+
const PARAMS = {
|
59
|
+
expected_grade_level: "6",
|
60
|
+
max_fkgl: 8,
|
61
|
+
max_words: 200,
|
62
|
+
max_rewrite_attempts: 3,
|
63
|
+
max_title_length: 50,
|
64
|
+
}
|
65
|
+
|
66
|
+
const whichTargetAudienceAndEstimatedDuration = async () => {
|
67
|
+
const res = await prompts([
|
68
|
+
{
|
69
|
+
type: "text",
|
70
|
+
name: "targetAudience",
|
71
|
+
message: "What is the target audience for this tutorial?",
|
72
|
+
},
|
73
|
+
{
|
74
|
+
type: "number",
|
75
|
+
name: "estimatedDuration",
|
76
|
+
message: "What is the estimated duration for this tutorial in minutes?",
|
77
|
+
},
|
78
|
+
])
|
79
|
+
return {
|
80
|
+
targetAudience: res.targetAudience,
|
81
|
+
estimatedDuration: res.estimatedDuration,
|
82
|
+
}
|
83
|
+
}
|
84
|
+
|
85
|
+
async function processExercise(
|
86
|
+
rigoToken: string,
|
87
|
+
steps: string[],
|
88
|
+
packageContext: string,
|
89
|
+
exercise: any,
|
90
|
+
exercisesDir: string
|
91
|
+
): Promise<string> {
|
92
|
+
const { exNumber, exTitle, kind, description } = getExInfo(exercise)
|
93
|
+
const exerciseDir = path.join(exercisesDir, `${exNumber}-${exTitle}`)
|
94
|
+
|
95
|
+
const readme = await readmeCreator(
|
96
|
+
rigoToken,
|
97
|
+
{
|
98
|
+
title: `${exNumber} - ${exTitle}`,
|
99
|
+
output_lang: "en",
|
100
|
+
list_of_exercises: steps.join(","),
|
101
|
+
tutorial_description: packageContext,
|
102
|
+
lesson_description: description,
|
103
|
+
kind: kind.toLowerCase(),
|
104
|
+
last_lesson: "",
|
105
|
+
},
|
106
|
+
"learnpack-lesson-writer"
|
107
|
+
)
|
108
|
+
|
109
|
+
const duration = durationByKind[kind.toLowerCase()]
|
110
|
+
let attempts = 0
|
111
|
+
let readability = checkReadability(readme.parsed.content, 200, duration || 1)
|
112
|
+
|
113
|
+
while (
|
114
|
+
readability.fkglResult.fkgl > PARAMS.max_fkgl &&
|
115
|
+
attempts < PARAMS.max_rewrite_attempts
|
116
|
+
) {
|
117
|
+
Console.warning(
|
118
|
+
`The lesson ${exTitle} has as readability score of ${
|
119
|
+
readability.fkglResult.fkgl
|
120
|
+
} . It exceeds the maximum of words per minute. Rewriting it... (Attempt ${
|
121
|
+
attempts + 1
|
122
|
+
})`
|
123
|
+
)
|
124
|
+
|
125
|
+
// eslint-disable-next-line
|
126
|
+
const reducedReadme = await makeReadmeReadable(
|
127
|
+
rigoToken,
|
128
|
+
{
|
129
|
+
lesson: readability.body,
|
130
|
+
number_of_words: readability.minutes.toString(),
|
131
|
+
expected_number_words: PARAMS.max_words.toString(),
|
132
|
+
fkgl_results: JSON.stringify(readability.fkglResult),
|
133
|
+
expected_grade_level: PARAMS.expected_grade_level,
|
134
|
+
},
|
135
|
+
"learnpack-lesson-writer"
|
136
|
+
)
|
137
|
+
|
138
|
+
// console.log("REDUCED README START", reducedReadme, "REDUCED README END")
|
139
|
+
|
140
|
+
if (!reducedReadme) break
|
141
|
+
|
142
|
+
readability = checkReadability(
|
143
|
+
reducedReadme.parsed.content,
|
144
|
+
PARAMS.max_words,
|
145
|
+
duration || 1
|
146
|
+
)
|
147
|
+
|
148
|
+
attempts++
|
149
|
+
}
|
150
|
+
|
151
|
+
Console.success(
|
152
|
+
`After ${attempts} attempts, the lesson ${exTitle} has a readability score of ${
|
153
|
+
readability.fkglResult.fkgl
|
154
|
+
} using FKGL. And it has ${readability.minutes.toFixed(
|
155
|
+
2
|
156
|
+
)} minutes of reading time.`
|
157
|
+
)
|
158
|
+
|
159
|
+
const readmeFilename = "README.md"
|
160
|
+
fs.writeFileSync(
|
161
|
+
path.join(exerciseDir, readmeFilename),
|
162
|
+
readability.newMarkdown
|
163
|
+
)
|
164
|
+
|
165
|
+
if (kind.toLowerCase() === "code") {
|
166
|
+
const codeFile = await createCodeFile(rigoToken, {
|
167
|
+
readme: readability.newMarkdown,
|
168
|
+
tutorial_info: packageContext,
|
169
|
+
})
|
170
|
+
|
171
|
+
fs.writeFileSync(
|
172
|
+
path.join(
|
173
|
+
exerciseDir,
|
174
|
+
`app.${codeFile.parsed.extension.replace(".", "")}`
|
175
|
+
),
|
176
|
+
codeFile.parsed.content
|
177
|
+
)
|
178
|
+
}
|
179
|
+
|
180
|
+
return readability.newMarkdown
|
181
|
+
}
|
182
|
+
|
183
|
+
const initializeInteractiveCreation = async (
|
184
|
+
rigoToken: string,
|
185
|
+
courseInfo: string
|
186
|
+
): Promise<{
|
187
|
+
steps: string[]
|
188
|
+
title: string
|
189
|
+
description: string
|
190
|
+
interactions: string
|
191
|
+
difficulty: string
|
192
|
+
duration: number
|
193
|
+
}> => {
|
194
|
+
let prevInteractions = ""
|
195
|
+
let isReady = false
|
196
|
+
let currentSteps = []
|
197
|
+
let currentTitle = ""
|
198
|
+
let currentDescription = ""
|
199
|
+
let currentDifficulty = ""
|
200
|
+
|
201
|
+
while (!isReady) {
|
202
|
+
const spinner = ora("Thinking...").start()
|
203
|
+
let wholeInfo = courseInfo
|
204
|
+
wholeInfo += `
|
205
|
+
Current title: ${currentTitle}
|
206
|
+
Current description: ${currentDescription}
|
207
|
+
`
|
208
|
+
// eslint-disable-next-line
|
209
|
+
const res = await interactiveCreation(rigoToken, {
|
210
|
+
courseInfo: wholeInfo,
|
211
|
+
prevInteractions: prevInteractions,
|
212
|
+
})
|
213
|
+
spinner.succeed("Done!")
|
214
|
+
currentSteps = res.parsed.listOfSteps
|
215
|
+
isReady = res.parsed.ready
|
216
|
+
if (res.parsed.title && currentTitle !== res.parsed.title) {
|
217
|
+
currentTitle = res.parsed.title
|
218
|
+
}
|
219
|
+
|
220
|
+
if (
|
221
|
+
res.parsed.description &&
|
222
|
+
currentDescription !== res.parsed.description
|
223
|
+
) {
|
224
|
+
currentDescription = res.parsed.description
|
225
|
+
}
|
226
|
+
|
227
|
+
if (res.parsed.difficulty && currentDifficulty !== res.parsed.difficulty) {
|
228
|
+
currentDifficulty = res.parsed.difficulty
|
229
|
+
}
|
230
|
+
|
231
|
+
if (!isReady) {
|
232
|
+
console.log(currentSteps)
|
233
|
+
Console.info(`AI: ${res.parsed.aiMessage}`)
|
234
|
+
prevInteractions += `\nAI: ${res.parsed.aiMessage}`
|
235
|
+
// eslint-disable-next-line
|
236
|
+
const userMessage = await prompts([
|
237
|
+
{
|
238
|
+
type: "text",
|
239
|
+
name: "userMessage",
|
240
|
+
message: "Your message: ",
|
241
|
+
},
|
242
|
+
])
|
243
|
+
prevInteractions += `\nUser: ${userMessage.userMessage}`
|
244
|
+
}
|
245
|
+
}
|
246
|
+
|
247
|
+
const duration = estimateDuration(currentSteps)
|
248
|
+
return {
|
249
|
+
steps: currentSteps,
|
250
|
+
title: currentTitle,
|
251
|
+
description: currentDescription,
|
252
|
+
interactions: prevInteractions,
|
253
|
+
difficulty: currentDifficulty,
|
254
|
+
duration,
|
255
|
+
}
|
256
|
+
}
|
257
|
+
|
258
|
+
process.emitWarning = (warning: string) => {
|
259
|
+
Console.debug("A Warning was emitted by Node.js: ", warning)
|
260
|
+
}
|
261
|
+
|
262
|
+
const handleAILogic = async (tutorialDir: string, packageInfo: PackageInfo) => {
|
263
|
+
fs.removeSync(path.join(tutorialDir, "exercises", "01-hello-world"))
|
264
|
+
|
265
|
+
let sessionPayload = await SessionManager.getPayload()
|
266
|
+
|
267
|
+
const sessionExists = sessionPayload && sessionPayload.rigobot
|
268
|
+
|
269
|
+
const isValidToken =
|
270
|
+
sessionExists && sessionPayload.rigobot.key ?
|
271
|
+
await isValidRigoToken(sessionPayload.rigobot.key) :
|
272
|
+
false
|
273
|
+
|
274
|
+
const isValidBreathecodeToken =
|
275
|
+
sessionExists && sessionPayload.token ?
|
276
|
+
await api.validateToken(sessionPayload.token) :
|
277
|
+
false
|
278
|
+
|
279
|
+
if (!sessionExists || !isValidBreathecodeToken || !isValidToken) {
|
280
|
+
Console.info(
|
281
|
+
"Almost there! First you need to login with 4Geeks.com to use AI Generation tool for creators. You can create a new account here: https://4geeks.com/checkout?plan=4geeks-creator"
|
282
|
+
)
|
283
|
+
try {
|
284
|
+
sessionPayload = await SessionManager.login()
|
285
|
+
} catch (error) {
|
286
|
+
Console.error("Error trying to authenticate")
|
287
|
+
Console.error((error as TypeError).message || (error as string))
|
288
|
+
}
|
289
|
+
}
|
290
|
+
|
291
|
+
const rigoToken = sessionPayload.rigobot.key
|
292
|
+
|
293
|
+
const consumable = await getConsumable(sessionPayload.token, "ai-generation")
|
294
|
+
|
295
|
+
if (consumable.count === 0) {
|
296
|
+
Console.error(
|
297
|
+
"It seems you cannot generate tutorials with AI. Make sure you creator subscription is up to date here: https://4geeks.com/profile/subscriptions. If you believe there is an issue you can always contact support@4geeks.com"
|
298
|
+
)
|
299
|
+
process.exit(1)
|
300
|
+
}
|
301
|
+
|
302
|
+
// const isCreator = await hasCreatorPermission(rigoToken)
|
303
|
+
// if (!isCreator) {
|
304
|
+
// Console.error(
|
305
|
+
// "👀 Oops! You need to be a creator to use our specialized AI. Please contact support"
|
306
|
+
// )
|
307
|
+
// process.exit(1)
|
308
|
+
// }
|
309
|
+
|
310
|
+
Console.success("🎉 Let's begin this learning journey!")
|
311
|
+
|
312
|
+
const { targetAudience, estimatedDuration } =
|
313
|
+
await whichTargetAudienceAndEstimatedDuration()
|
314
|
+
const contentIndex = await appendContentIndex()
|
315
|
+
const airules = await appendAIRules()
|
316
|
+
|
317
|
+
if (airules) {
|
318
|
+
const rulesDir = path.join(tutorialDir, ".learn", "rules")
|
319
|
+
|
320
|
+
// Crear el directorio si no existe
|
321
|
+
fs.mkdirSync(rulesDir, { recursive: true })
|
322
|
+
|
323
|
+
fs.writeFileSync(path.join(rulesDir, "airules.txt"), airules)
|
324
|
+
}
|
325
|
+
|
326
|
+
let packageContext = `
|
327
|
+
\n
|
328
|
+
Title: "${packageInfo.title.us}"
|
329
|
+
Description: "${packageInfo.description.us}"
|
330
|
+
Target Audience: "${targetAudience}"
|
331
|
+
Estimated Duration: "${estimatedDuration} minutes"
|
332
|
+
|
333
|
+
${
|
334
|
+
contentIndex ?
|
335
|
+
`Content Index submitted by the user, use this to guide your creation. Keep in mind that your tutorial should contain these topics:
|
336
|
+
---
|
337
|
+
${contentIndex}
|
338
|
+
---
|
339
|
+
` :
|
340
|
+
""
|
341
|
+
}
|
342
|
+
|
343
|
+
This is the duration for each type of step, use it to estimate the number of steps to create:
|
344
|
+
${Object.entries(durationByKind)
|
345
|
+
.map(([key, value]) => `${key}: ${value} minutes`)
|
346
|
+
.join("\n")}
|
347
|
+
|
348
|
+
|
349
|
+
Within the estimated duration, is possible to have the following activities:
|
350
|
+
Format=
|
351
|
+
Activity: Maximum number of steps for duration
|
352
|
+
|
353
|
+
Estimated activities:
|
354
|
+
${JSON.stringify(estimateActivities(estimatedDuration))}
|
355
|
+
|
356
|
+
You should create a tutorial that is engaging and fun to follow.
|
357
|
+
|
358
|
+
|
359
|
+
${
|
360
|
+
airules ?
|
361
|
+
`
|
362
|
+
This is a list of rules you need to follow when creating the tutorial:
|
363
|
+
${airules}
|
364
|
+
` :
|
365
|
+
""
|
366
|
+
}
|
367
|
+
`
|
368
|
+
|
369
|
+
const { steps, title, description, duration, difficulty } =
|
370
|
+
await initializeInteractiveCreation(rigoToken, packageContext)
|
371
|
+
packageInfo.title.us = title
|
372
|
+
packageInfo.description.us = description
|
373
|
+
packageInfo.duration = duration
|
374
|
+
packageInfo.difficulty = difficulty
|
375
|
+
|
376
|
+
packageContext = `
|
377
|
+
Title: "${title}"
|
378
|
+
Description: "${description}"
|
379
|
+
Target Audience: "${targetAudience}"
|
380
|
+
List of exercises: ${steps.join(", ")}
|
381
|
+
|
382
|
+
AI Rules: ${airules}
|
383
|
+
`
|
384
|
+
const exercisesDir = path.join(tutorialDir, "exercises")
|
385
|
+
fs.ensureDirSync(exercisesDir)
|
386
|
+
|
387
|
+
Console.info("Creating lessons...")
|
388
|
+
for (const [index, exercise] of steps.entries()) {
|
389
|
+
const { exNumber, exTitle } = getExInfo(exercise)
|
390
|
+
const exerciseDir = path.join(exercisesDir, `${exNumber}-${exTitle}`)
|
391
|
+
fs.ensureDirSync(exerciseDir)
|
392
|
+
}
|
393
|
+
|
394
|
+
const exercisePromises = steps.map(exercise =>
|
395
|
+
processExercise(rigoToken, steps, packageContext, exercise, exercisesDir)
|
396
|
+
)
|
397
|
+
|
398
|
+
const readmeContents = await Promise.all(exercisePromises)
|
399
|
+
Console.success("Lessons created! 🎉")
|
400
|
+
|
401
|
+
Console.info("Generating images for the lessons...")
|
402
|
+
let imagesArray: any[] = []
|
403
|
+
|
404
|
+
for (const content of readmeContents) {
|
405
|
+
imagesArray = [...imagesArray, ...extractImagesFromMarkdown(content)]
|
406
|
+
}
|
407
|
+
|
408
|
+
const imagePromises = imagesArray.map(async (image: any) => {
|
409
|
+
try {
|
410
|
+
const filename = getFilenameFromUrl(image.url)
|
411
|
+
const webhookUrl = `${process.env.HOST}/webhooks/${slugify(
|
412
|
+
packageInfo.title.us
|
413
|
+
)}/images/${filename}`
|
414
|
+
|
415
|
+
const imagePath = path.join(tutorialDir, ".learn", "assets", filename)
|
416
|
+
|
417
|
+
const res = await generateImage(rigoToken, {
|
418
|
+
prompt: image.alt,
|
419
|
+
callbackUrl: webhookUrl,
|
420
|
+
})
|
421
|
+
await downloadImage(res.image_url, imagePath)
|
422
|
+
return true
|
423
|
+
} catch {
|
424
|
+
Console.error(`Error downloading image ${image.url}`)
|
425
|
+
|
426
|
+
return false
|
427
|
+
}
|
428
|
+
})
|
429
|
+
await Promise.all(imagePromises)
|
430
|
+
Console.info(
|
431
|
+
"Images generated successfully! 🎉 Your tutorial will be ready soon!"
|
432
|
+
)
|
433
|
+
|
434
|
+
Console.info("Creating preview readme...")
|
435
|
+
|
436
|
+
await createPreviewReadme(tutorialDir, packageInfo, rigoToken, readmeContents)
|
437
|
+
const imagePath = path.join(tutorialDir, "preview.png")
|
438
|
+
|
439
|
+
const res = await generateImage(rigoToken, {
|
440
|
+
prompt:
|
441
|
+
"Generate a preview image for the tutorial. This is all the tutorial information: " +
|
442
|
+
packageContext +
|
443
|
+
"\n Generate only a basic preview image, add the tutorial Title as a text add the top middle, avoid adding any other text elements. Try to generate an image that related with the tutorial content.",
|
444
|
+
callbackUrl: `${process.env.HOST}/webhooks/${slugify(
|
445
|
+
packageInfo.title.us
|
446
|
+
)}/images/preview.png`,
|
447
|
+
})
|
448
|
+
|
449
|
+
// await downloadImage(res.image_url, imagePath)
|
450
|
+
|
451
|
+
return true
|
452
|
+
}
|
453
|
+
|
454
|
+
const getChoices = async (empty: boolean) => {
|
455
|
+
const defaultChoices = {
|
456
|
+
title: "My Interactive Tutorial",
|
457
|
+
description: "",
|
458
|
+
difficulty: "beginner",
|
459
|
+
duration: 5,
|
460
|
+
useAI: "no",
|
461
|
+
grading: "isolated",
|
462
|
+
}
|
463
|
+
|
464
|
+
if (empty) {
|
465
|
+
return defaultChoices
|
466
|
+
}
|
467
|
+
|
468
|
+
const choices = await prompts([
|
469
|
+
{
|
470
|
+
type: "select",
|
471
|
+
name: "grading",
|
472
|
+
message: "How are you going to grade students or yourself?",
|
473
|
+
choices: [
|
474
|
+
{
|
475
|
+
title:
|
476
|
+
"No grading: No feedback or testing whatsoever, similar to a an interactive book.",
|
477
|
+
value: null,
|
478
|
+
},
|
479
|
+
{
|
480
|
+
title: "Isolated: Each step is a new separate exercise",
|
481
|
+
value: "isolated",
|
482
|
+
},
|
483
|
+
{
|
484
|
+
title:
|
485
|
+
"Step by step: Each step builds on top of each other like an incremental tutorial",
|
486
|
+
value: "incremental",
|
487
|
+
},
|
488
|
+
],
|
489
|
+
},
|
490
|
+
{
|
491
|
+
type: "text",
|
492
|
+
|
493
|
+
name: "title",
|
494
|
+
initial: "My Interactive Tutorial",
|
495
|
+
message: "Title for your tutorial? Press enter to leave as it is",
|
496
|
+
validate: (value: string) => {
|
497
|
+
if (value.length > PARAMS.max_title_length) {
|
498
|
+
return `Title must be less than ${PARAMS.max_title_length} characters`
|
499
|
+
}
|
500
|
+
|
501
|
+
return true
|
502
|
+
},
|
503
|
+
},
|
504
|
+
{
|
505
|
+
type: "text",
|
506
|
+
name: "description",
|
507
|
+
initial: "",
|
508
|
+
message: "Description for your tutorial? Press enter to leave blank",
|
509
|
+
},
|
510
|
+
|
511
|
+
{
|
512
|
+
type: "select",
|
513
|
+
name: "useAI",
|
514
|
+
message:
|
515
|
+
"Want a little bit of AI magic to help you? Our AI can craft the tutorial for you",
|
516
|
+
choices: [
|
517
|
+
{
|
518
|
+
title: "Yes, please help me",
|
519
|
+
value: "yes",
|
520
|
+
},
|
521
|
+
{ title: "No, thanks, I prefer to do it manually", value: "no" },
|
522
|
+
],
|
523
|
+
},
|
524
|
+
])
|
525
|
+
|
526
|
+
const completeChoices = {
|
527
|
+
...choices,
|
528
|
+
difficulty: "beginner",
|
529
|
+
duration: 30,
|
530
|
+
}
|
531
|
+
return completeChoices
|
532
|
+
}
|
533
|
+
|
534
|
+
class InitComand extends BaseCommand {
|
535
|
+
static description =
|
536
|
+
"Create a new learning package: Book, Tutorial or Exercise"
|
537
|
+
|
538
|
+
static flags = {
|
539
|
+
...BaseCommand.flags,
|
540
|
+
grading: flags.help({ char: "h" }),
|
541
|
+
}
|
542
|
+
|
543
|
+
async run() {
|
544
|
+
const { flags } = this.parse(InitComand)
|
545
|
+
|
546
|
+
await alreadyInitialized()
|
547
|
+
|
548
|
+
const choices = await getChoices(flags.yes)
|
549
|
+
|
550
|
+
const packageInfo = makePackageInfo(choices)
|
551
|
+
|
552
|
+
const tutorialDir = `./${packageInfo.slug}`
|
553
|
+
fs.ensureDirSync(tutorialDir)
|
554
|
+
|
555
|
+
const templatesDir = path.resolve(
|
556
|
+
__dirname,
|
557
|
+
"../../src/utils/templates/" + (choices.grading || "no-grading")
|
558
|
+
)
|
559
|
+
if (!fs.existsSync(templatesDir))
|
560
|
+
throw ValidationError(`Template ${templatesDir} does not exists`)
|
561
|
+
await fs.copySync(templatesDir, tutorialDir)
|
562
|
+
|
563
|
+
if (choices.useAI === "yes") {
|
564
|
+
await handleAILogic(tutorialDir, packageInfo)
|
565
|
+
}
|
566
|
+
|
567
|
+
const languages = ["en", "es"]
|
568
|
+
|
569
|
+
// Creating README files
|
570
|
+
for (const language of languages) {
|
571
|
+
const readmeFilename = `README${language !== "en" ? `.${language}` : ""}`
|
572
|
+
const readmeTemplatePath = path.resolve(
|
573
|
+
templatesDir,
|
574
|
+
`${readmeFilename}.ejs`
|
575
|
+
)
|
576
|
+
|
577
|
+
if (choices.useAI !== "yes") {
|
578
|
+
const readmeObject = {
|
579
|
+
title: packageInfo.title.us,
|
580
|
+
description: packageInfo.description.us,
|
581
|
+
grading: packageInfo.grading,
|
582
|
+
difficulty: packageInfo.difficulty,
|
583
|
+
duration: packageInfo.duration,
|
584
|
+
}
|
585
|
+
const readmeContent = eta.render(
|
586
|
+
fs.readFileSync(readmeTemplatePath, "utf-8"),
|
587
|
+
readmeObject
|
588
|
+
)
|
589
|
+
fs.writeFileSync(
|
590
|
+
path.join(tutorialDir, `${readmeFilename}.md`),
|
591
|
+
readmeContent
|
592
|
+
)
|
593
|
+
}
|
594
|
+
|
595
|
+
if (fs.existsSync(path.join(tutorialDir, `${readmeFilename}.ejs`)))
|
596
|
+
fs.removeSync(path.join(tutorialDir, `${readmeFilename}.ejs`))
|
597
|
+
}
|
598
|
+
|
599
|
+
cli.action.start("Initializing package")
|
600
|
+
|
601
|
+
if (!fs.existsSync(path.join(tutorialDir, ".gitignore")))
|
602
|
+
fs.copyFile(
|
603
|
+
path.resolve(__dirname, "../../src/utils/templates/gitignore.txt"),
|
604
|
+
path.join(tutorialDir, ".gitignore")
|
605
|
+
)
|
606
|
+
|
607
|
+
fs.writeFileSync(
|
608
|
+
path.join(tutorialDir, "learn.json"),
|
609
|
+
JSON.stringify(packageInfo, null, 2)
|
610
|
+
)
|
611
|
+
|
612
|
+
cli.action.stop()
|
613
|
+
Console.success(`😋 Package initialized successfully in ${tutorialDir}`)
|
614
|
+
Console.help(
|
615
|
+
`Get inside the tutorial with the command: $ cd ${tutorialDir}`
|
616
|
+
)
|
617
|
+
Console.help(
|
618
|
+
`Start the exercises by running the following command on your terminal: $ learnpack start`
|
619
|
+
)
|
620
|
+
process.exit(0)
|
621
|
+
}
|
622
|
+
}
|
623
|
+
|
624
|
+
const alreadyInitialized = () =>
|
625
|
+
new Promise((resolve, reject) => {
|
626
|
+
fs.readdir("./", function (err: any, files: any) {
|
627
|
+
files = files.filter((f: any) =>
|
628
|
+
[".learn", "learn.json", "bc.json", ".breathecode"].includes(f)
|
629
|
+
)
|
630
|
+
if (err) {
|
631
|
+
reject(ValidationError(err.message))
|
632
|
+
throw ValidationError(err.message)
|
633
|
+
} else if (files.length > 0) {
|
634
|
+
reject(
|
635
|
+
ValidationError(
|
636
|
+
"It seems the package is already initialized because we've found the following files: " +
|
637
|
+
files.join(",")
|
638
|
+
)
|
639
|
+
)
|
640
|
+
throw ValidationError(
|
641
|
+
"It seems the package is already initialized because we've found the following files: " +
|
642
|
+
files.join(",")
|
643
|
+
)
|
644
|
+
}
|
645
|
+
|
646
|
+
resolve(false)
|
647
|
+
})
|
648
|
+
})
|
649
|
+
|
650
|
+
export default InitComand
|