@learnpack/learnpack 5.0.128 → 5.0.132
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 +13 -13
- package/lib/commands/serve.d.ts +51 -0
- package/lib/commands/serve.js +145 -2
- package/lib/utils/creatorUtilities.d.ts +1 -0
- package/lib/utils/creatorUtilities.js +17 -16
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
- package/src/commands/serve.ts +301 -4
- package/src/creator/src/components/syllabus/SyllabusEditor.tsx +11 -54
- package/src/utils/cloudStorage.ts +24 -24
- package/src/utils/creatorUtilities.ts +499 -499
@@ -1,499 +1,499 @@
|
|
1
|
-
import Console from "./console"
|
2
|
-
|
3
|
-
const frontMatter = require("front-matter")
|
4
|
-
import * as MarkdownIt from "markdown-it"
|
5
|
-
import { syllable } from "syllable"
|
6
|
-
|
7
|
-
import * as path from "path"
|
8
|
-
import * as fs from "fs"
|
9
|
-
import { homedir } from "os"
|
10
|
-
import { join } from "path"
|
11
|
-
import { exec } from "child_process"
|
12
|
-
import { promisify } from "util"
|
13
|
-
|
14
|
-
import * as yaml from "js-yaml"
|
15
|
-
import * as prompts from "prompts"
|
16
|
-
|
17
|
-
type TEstimateReadingTimeReturns = {
|
18
|
-
minutes: number
|
19
|
-
words: number
|
20
|
-
}
|
21
|
-
|
22
|
-
export const estimateReadingTime = (
|
23
|
-
text: string,
|
24
|
-
wordsPerMinute = 150
|
25
|
-
): TEstimateReadingTimeReturns => {
|
26
|
-
const words = text.trim().split(/\s+/).length
|
27
|
-
const minutes = words / wordsPerMinute
|
28
|
-
|
29
|
-
if (minutes < 1) {
|
30
|
-
if (words === 0)
|
31
|
-
return {
|
32
|
-
minutes: 1,
|
33
|
-
words,
|
34
|
-
}
|
35
|
-
} else {
|
36
|
-
return {
|
37
|
-
minutes,
|
38
|
-
words,
|
39
|
-
}
|
40
|
-
}
|
41
|
-
|
42
|
-
return {
|
43
|
-
minutes: 1,
|
44
|
-
words,
|
45
|
-
}
|
46
|
-
}
|
47
|
-
|
48
|
-
export type PackageInfo = {
|
49
|
-
grading: string
|
50
|
-
difficulty: string
|
51
|
-
duration: number
|
52
|
-
description: {
|
53
|
-
us: string
|
54
|
-
}
|
55
|
-
title: {
|
56
|
-
us: string
|
57
|
-
}
|
58
|
-
}
|
59
|
-
|
60
|
-
type TFKGLResult = {
|
61
|
-
text: string
|
62
|
-
fkgl: number
|
63
|
-
}
|
64
|
-
|
65
|
-
export function checkReadability(
|
66
|
-
markdown: string,
|
67
|
-
wordsPerMinute = 200,
|
68
|
-
maxMinutes = 1
|
69
|
-
): {
|
70
|
-
newMarkdown: string
|
71
|
-
exceedsThreshold: boolean
|
72
|
-
minutes: number
|
73
|
-
body: string
|
74
|
-
fkglResult: TFKGLResult
|
75
|
-
// readingEase: number
|
76
|
-
} {
|
77
|
-
const parsed = frontMatter(markdown)
|
78
|
-
|
79
|
-
const fkglResult = fleschKincaidGrade(parsed.body)
|
80
|
-
|
81
|
-
const readingTime = estimateReadingTime(parsed.body, wordsPerMinute)
|
82
|
-
|
83
|
-
// const readingEase = estimateReadingEase(parsed.body)
|
84
|
-
let attributes = parsed.attributes ? parsed.attributes : {}
|
85
|
-
|
86
|
-
if (typeof parsed.attributes !== "object") {
|
87
|
-
attributes = {}
|
88
|
-
}
|
89
|
-
|
90
|
-
const updatedAttributes = {
|
91
|
-
...attributes,
|
92
|
-
readingTime,
|
93
|
-
fkglResult: fkglResult.fkgl,
|
94
|
-
}
|
95
|
-
|
96
|
-
let yamlFrontMatter = ""
|
97
|
-
try {
|
98
|
-
yamlFrontMatter = yaml.dump(updatedAttributes).trim()
|
99
|
-
} catch {
|
100
|
-
Console.error("Error dumping YAML front matter")
|
101
|
-
return {
|
102
|
-
newMarkdown: "",
|
103
|
-
exceedsThreshold: false,
|
104
|
-
minutes: 0,
|
105
|
-
body: "",
|
106
|
-
fkglResult,
|
107
|
-
// readingEase: 0,
|
108
|
-
}
|
109
|
-
}
|
110
|
-
|
111
|
-
const newMarkdown = `---\n${yamlFrontMatter}\n---\n\n${parsed.body}`
|
112
|
-
|
113
|
-
return {
|
114
|
-
newMarkdown,
|
115
|
-
exceedsThreshold: readingTime.minutes > maxMinutes,
|
116
|
-
minutes: readingTime.minutes,
|
117
|
-
body: parsed.body,
|
118
|
-
fkglResult,
|
119
|
-
}
|
120
|
-
}
|
121
|
-
|
122
|
-
const slugify = (text: string) => {
|
123
|
-
return text
|
124
|
-
.toString()
|
125
|
-
.normalize("NFD")
|
126
|
-
.replace(/[\u0300-\u036F]/g, "")
|
127
|
-
.toLowerCase()
|
128
|
-
.trim()
|
129
|
-
.replace(/\s+/g, "-")
|
130
|
-
.replace(/[^\w-]+/g, "")
|
131
|
-
}
|
132
|
-
|
133
|
-
export const getExInfo = (title: string) => {
|
134
|
-
// Example title: '1.0 - Introduction to AI [READ: Small introduction to important concepts such as AI, machine learning, and their applications]'
|
135
|
-
let [exNumber, exTitle] = title.split(" - ")
|
136
|
-
|
137
|
-
// Extract kind and description
|
138
|
-
const kindMatch = exTitle.match(/\[(.*?):(.*?)]/)
|
139
|
-
const kind = kindMatch ? kindMatch[1].trim().toLowerCase() : "read"
|
140
|
-
const description = kindMatch ? kindMatch[2].trim() : ""
|
141
|
-
|
142
|
-
exNumber = exNumber.trim()
|
143
|
-
// Clean title
|
144
|
-
exTitle = exTitle.replace(kindMatch?.[0] || "", "").trim()
|
145
|
-
exTitle = slugify(exTitle)
|
146
|
-
|
147
|
-
return {
|
148
|
-
exNumber,
|
149
|
-
kind,
|
150
|
-
description,
|
151
|
-
exTitle,
|
152
|
-
}
|
153
|
-
}
|
154
|
-
|
155
|
-
export function extractImagesFromMarkdown(markdown: string) {
|
156
|
-
const imageRegex = /!\[([^\]]*)]\(([^)]+)\)/g
|
157
|
-
const images = []
|
158
|
-
let match
|
159
|
-
|
160
|
-
while ((match = imageRegex.exec(markdown)) !== null) {
|
161
|
-
const altText = match[1]
|
162
|
-
const url = match[2]
|
163
|
-
images.push({ alt: altText, url: url })
|
164
|
-
}
|
165
|
-
|
166
|
-
return images
|
167
|
-
}
|
168
|
-
|
169
|
-
export function getFilenameFromUrl(url: string) {
|
170
|
-
return path.basename(url)
|
171
|
-
}
|
172
|
-
|
173
|
-
export const makePackageInfo = (choices: any) => {
|
174
|
-
const packageInfo = {
|
175
|
-
grading: choices.grading,
|
176
|
-
difficulty: "beginner",
|
177
|
-
duration: 5,
|
178
|
-
description: {
|
179
|
-
us: choices.description,
|
180
|
-
},
|
181
|
-
title: {
|
182
|
-
us: choices.title,
|
183
|
-
},
|
184
|
-
slug: choices.title
|
185
|
-
.toLowerCase()
|
186
|
-
.replace(/ /g, "-")
|
187
|
-
.replace(/[^\w-]+/g, ""),
|
188
|
-
telemetry: {
|
189
|
-
batch: "https://breathecode.herokuapp.com/v1/assignment/me/telemetry",
|
190
|
-
},
|
191
|
-
}
|
192
|
-
return packageInfo
|
193
|
-
}
|
194
|
-
|
195
|
-
export function estimateDuration(listOfSteps: string[]): number {
|
196
|
-
let duration = 0
|
197
|
-
|
198
|
-
for (const step of listOfSteps) {
|
199
|
-
if (step.includes("[READ:")) {
|
200
|
-
duration += 1
|
201
|
-
} else if (step.includes("[QUIZ:")) {
|
202
|
-
duration += 2
|
203
|
-
} else if (step.includes("[CODE:")) {
|
204
|
-
duration += 3
|
205
|
-
}
|
206
|
-
}
|
207
|
-
|
208
|
-
return duration
|
209
|
-
}
|
210
|
-
|
211
|
-
const writeFilePromise = promisify(fs.writeFile)
|
212
|
-
const execPromise = promisify(exec)
|
213
|
-
|
214
|
-
export async function createFileOnDesktop(fileName: string, content: string) {
|
215
|
-
try {
|
216
|
-
const desktopPath = join(homedir(), "Desktop")
|
217
|
-
const filePath = join(desktopPath, fileName)
|
218
|
-
|
219
|
-
await writeFilePromise(filePath, content)
|
220
|
-
console.log(`File created successfully at: ${filePath}`)
|
221
|
-
|
222
|
-
await openFile(filePath)
|
223
|
-
} catch (error) {
|
224
|
-
console.error("Error:", error)
|
225
|
-
}
|
226
|
-
}
|
227
|
-
|
228
|
-
async function openFile(filePath: string) {
|
229
|
-
const platform = process.platform
|
230
|
-
let command
|
231
|
-
|
232
|
-
if (platform === "win32") {
|
233
|
-
command = `start "" "${filePath}"`
|
234
|
-
} else if (platform === "darwin") {
|
235
|
-
command = `open "${filePath}"`
|
236
|
-
} else {
|
237
|
-
command = `xdg-open "${filePath}"`
|
238
|
-
}
|
239
|
-
|
240
|
-
try {
|
241
|
-
await execPromise(command)
|
242
|
-
console.log("File opened successfully.")
|
243
|
-
} catch (error) {
|
244
|
-
console.error("Error opening the file:", error)
|
245
|
-
}
|
246
|
-
}
|
247
|
-
|
248
|
-
export function getDesktopFile(fileName: string) {
|
249
|
-
const desktopPath = join(homedir(), "Desktop")
|
250
|
-
const filePath = join(desktopPath, fileName)
|
251
|
-
|
252
|
-
const content = fs.readFileSync(filePath, "utf8")
|
253
|
-
// Delete the file after reading it
|
254
|
-
// fs.unlinkSync(filePath)
|
255
|
-
return content
|
256
|
-
}
|
257
|
-
|
258
|
-
// export function fleschKincaidReadingEase(text: string): number {
|
259
|
-
// const sentences = text.split(/[.!?]/).filter((s) => s.trim().length > 0)
|
260
|
-
// const words = text.split(/\s+/).filter((w) => w.trim().length > 0)
|
261
|
-
// const totalSyllables = words.reduce((sum, word) => sum + syllable(word), 0)
|
262
|
-
|
263
|
-
// const ASL = words.length / sentences.length // Average Sentence Length
|
264
|
-
// const ASW = totalSyllables / words.length // Average Syllables per Word
|
265
|
-
|
266
|
-
// return Math.round(206.835 - 1.015 * ASL - 84.6 * ASW)
|
267
|
-
// }
|
268
|
-
|
269
|
-
export function extractTextFromMarkdown(mdContent: string): string {
|
270
|
-
return mdContent
|
271
|
-
.replace(/!\[.*?]\(.*?\)/g, "") // Remove images
|
272
|
-
.replace(/\[.*?]\(.*?\)/g, "") // Remove links
|
273
|
-
.replace(/(```[\S\s]*?```|`.*?`)/g, "") // Remove inline & block code
|
274
|
-
.replace(/^#.*$/gm, "") // Remove headings
|
275
|
-
.replace(/(\*{1,2}|_{1,2})/g, "") // Remove bold/italic markers
|
276
|
-
.replace(/>\s?/g, "") // Remove blockquotes
|
277
|
-
.replace(/[*-]\s+/g, "") // Remove bullets from lists
|
278
|
-
.trim()
|
279
|
-
}
|
280
|
-
|
281
|
-
const cleanReadme = (readme: string) => {
|
282
|
-
// Replace <text> and </text> with nothing
|
283
|
-
return readme.replace(/<text>/g, "").replace(/<\/text>/g, "")
|
284
|
-
}
|
285
|
-
|
286
|
-
export const saveTranslatedReadme = async (
|
287
|
-
exercise: string,
|
288
|
-
languageCode: string,
|
289
|
-
readme: string
|
290
|
-
) => {
|
291
|
-
const readmePath = path.join(
|
292
|
-
process.cwd(),
|
293
|
-
"exercises",
|
294
|
-
exercise,
|
295
|
-
`README.${languageCode}.md`
|
296
|
-
)
|
297
|
-
fs.writeFileSync(readmePath, cleanReadme(readme))
|
298
|
-
}
|
299
|
-
|
300
|
-
/**
|
301
|
-
* Extracts paragraphs from a Markdown string.
|
302
|
-
* @param markdownText The input Markdown string.
|
303
|
-
* @returns An array of paragraph contents.
|
304
|
-
*/
|
305
|
-
export function extractParagraphs(markdownText: string): string[] {
|
306
|
-
const md = new MarkdownIt()
|
307
|
-
const tokens = md.parse(markdownText, {})
|
308
|
-
const paragraphs: string[] = []
|
309
|
-
|
310
|
-
for (let i = 0; i < tokens.length; i++) {
|
311
|
-
if (
|
312
|
-
tokens[i].type === "paragraph_open" &&
|
313
|
-
tokens[i + 1]?.type === "inline"
|
314
|
-
) {
|
315
|
-
paragraphs.push(tokens[i + 1].content)
|
316
|
-
}
|
317
|
-
}
|
318
|
-
|
319
|
-
return paragraphs
|
320
|
-
}
|
321
|
-
|
322
|
-
/**
|
323
|
-
* Splits a paragraph into words and separates each word into syllables.
|
324
|
-
* @param paragraph The input paragraph.
|
325
|
-
* @returns An array of words, each split into syllables.
|
326
|
-
*/
|
327
|
-
export function splitIntoSyllables(paragraph: string): string[] {
|
328
|
-
const words = paragraph.split(/\s+/)
|
329
|
-
const syllables: string[] = []
|
330
|
-
|
331
|
-
for (const word of words) {
|
332
|
-
syllables.push(...splitWordIntoSyllables(word))
|
333
|
-
}
|
334
|
-
|
335
|
-
return syllables
|
336
|
-
}
|
337
|
-
|
338
|
-
/**
|
339
|
-
* Splits a word into its syllables using a basic estimation.
|
340
|
-
* @param word The word to split.
|
341
|
-
* @returns An array of syllables.
|
342
|
-
*/
|
343
|
-
export function splitWordIntoSyllables(word: string): string[] {
|
344
|
-
const syllableCount = syllable(word)
|
345
|
-
|
346
|
-
// Simple heuristic: Split word into equal parts (not perfect, better with a dictionary-based approach)
|
347
|
-
if (syllableCount <= 1)
|
348
|
-
return [word]
|
349
|
-
|
350
|
-
const approxLength = Math.ceil(word.length / syllableCount)
|
351
|
-
const syllables: string[] = []
|
352
|
-
for (let i = 0; i < word.length; i += approxLength) {
|
353
|
-
// eslint-disable-next-line
|
354
|
-
syllables.push(word.substring(i, i + approxLength))
|
355
|
-
}
|
356
|
-
|
357
|
-
return syllables
|
358
|
-
}
|
359
|
-
|
360
|
-
/**
|
361
|
-
* Extracts words from a given paragraph.
|
362
|
-
* @param paragraph The input text.
|
363
|
-
* @returns An array of words.
|
364
|
-
*/
|
365
|
-
export function extractWords(paragraph: string): string[] {
|
366
|
-
const words = paragraph.match(/\b\w+\b/g) // Match words using regex
|
367
|
-
return words ? words : [] // Return words or an empty array if none found
|
368
|
-
}
|
369
|
-
|
370
|
-
/**
|
371
|
-
* Calculates the Flesch-Kincaid Grade Level (FKGL) for a given text.
|
372
|
-
* @param text The input paragraph.
|
373
|
-
* @returns The FKGL score.
|
374
|
-
*/
|
375
|
-
export function fleschKincaidGrade(text: string): TFKGLResult {
|
376
|
-
const processableText = extractTextFromMarkdown(text)
|
377
|
-
const words = extractWords(processableText)
|
378
|
-
const numWords = words.length
|
379
|
-
const numSentences = countSentences(processableText)
|
380
|
-
const numSyllables = words.reduce((total, word) => total + syllable(word), 0)
|
381
|
-
|
382
|
-
if (numWords === 0 || numSentences === 0) {
|
383
|
-
return {
|
384
|
-
text,
|
385
|
-
fkgl: 0,
|
386
|
-
}
|
387
|
-
}
|
388
|
-
|
389
|
-
const fkgl =
|
390
|
-
// eslint-disable-next-line
|
391
|
-
0.39 * (numWords / numSentences) + 11.8 * (numSyllables / numWords) - 15.59
|
392
|
-
|
393
|
-
return {
|
394
|
-
text,
|
395
|
-
fkgl: parseFloat(fkgl.toFixed(2)),
|
396
|
-
}
|
397
|
-
}
|
398
|
-
|
399
|
-
/**
|
400
|
-
* Counts the number of sentences in a given text.
|
401
|
-
* @param text The input paragraph.
|
402
|
-
* @returns The total number of sentences.
|
403
|
-
*/
|
404
|
-
export function countSentences(text: string): number {
|
405
|
-
const sentences = text
|
406
|
-
.split(/[!.?]+/)
|
407
|
-
.filter(sentence => sentence.trim().length > 0)
|
408
|
-
return sentences.length
|
409
|
-
}
|
410
|
-
|
411
|
-
export function howManyDifficultParagraphs(
|
412
|
-
paragraphs: TFKGLResult[],
|
413
|
-
maxFKGL: number
|
414
|
-
): number {
|
415
|
-
return paragraphs.filter(paragraph => paragraph.fkgl > maxFKGL).length
|
416
|
-
}
|
417
|
-
|
418
|
-
const example_content = `Write or paste your table of content below this line, each topic should be defined on a new line, here is an example:
|
419
|
-
|
420
|
-
Introduction to AI: Explain what is AI and its applications
|
421
|
-
Introduction to Machine Learning: Explain what is machine learning and its applications
|
422
|
-
What is an AI Model: Explain what is an AI model and its applications
|
423
|
-
How to use an AI Model: Different APIs, local models, etc.
|
424
|
-
How to build an AI Model: Fine-tuning, data collection, cleaning and more.
|
425
|
-
`
|
426
|
-
|
427
|
-
export const appendContentIndex = async () => {
|
428
|
-
const choices = await prompts([
|
429
|
-
{
|
430
|
-
type: "confirm",
|
431
|
-
name: "contentIndex",
|
432
|
-
message: "Do you have a content index for this tutorial?",
|
433
|
-
},
|
434
|
-
])
|
435
|
-
if (choices.contentIndex) {
|
436
|
-
await createFileOnDesktop("content_index.txt", example_content)
|
437
|
-
Console.info(
|
438
|
-
"Please make the necessary in the recently created file in your desktop, it should automatically open. Edit the file to match your expectations and save it. Keep the same name and structure as the example file. Continue when ready."
|
439
|
-
)
|
440
|
-
const isReady = await prompts([
|
441
|
-
{
|
442
|
-
type: "confirm",
|
443
|
-
name: "isReady",
|
444
|
-
message: "Are you ready to continue?",
|
445
|
-
},
|
446
|
-
])
|
447
|
-
if (!isReady.isReady) {
|
448
|
-
Console.error("Please make the necessary changes and try again.")
|
449
|
-
process.exit(1)
|
450
|
-
}
|
451
|
-
|
452
|
-
const contentIndex = getDesktopFile("content_index.txt")
|
453
|
-
return contentIndex
|
454
|
-
}
|
455
|
-
|
456
|
-
return null
|
457
|
-
}
|
458
|
-
|
459
|
-
const example_airules = `
|
460
|
-
Write with an engaging tone, use simple words and avoid complex sentences.
|
461
|
-
Write in first person, as if you are talking to the reader.
|
462
|
-
Add mental maps to help the reader understand the content.
|
463
|
-
Add diagrams to help the reader understand the content.
|
464
|
-
No code exercises required
|
465
|
-
|
466
|
-
`
|
467
|
-
|
468
|
-
export const appendAIRules = async () => {
|
469
|
-
const choices = await prompts([
|
470
|
-
{
|
471
|
-
type: "confirm",
|
472
|
-
name: "airules",
|
473
|
-
message:
|
474
|
-
"Do you want to add any specific rules for the AI? (e.g. no code exercises, no quizzes, etc, a particular writting style, etc)",
|
475
|
-
},
|
476
|
-
])
|
477
|
-
if (choices.airules) {
|
478
|
-
await createFileOnDesktop("airules.txt", example_airules)
|
479
|
-
Console.info(
|
480
|
-
"Please make the necessary in the recently created file in your desktop, it should automatically open. Edit the file to match your expectations and save it. Keep the same name and structure as the example file. Continue when ready."
|
481
|
-
)
|
482
|
-
const isReady = await prompts([
|
483
|
-
{
|
484
|
-
type: "confirm",
|
485
|
-
name: "isReady",
|
486
|
-
message: "Are you ready to continue?",
|
487
|
-
},
|
488
|
-
])
|
489
|
-
if (!isReady.isReady) {
|
490
|
-
Console.error("Please make the necessary changes and try again.")
|
491
|
-
process.exit(1)
|
492
|
-
}
|
493
|
-
|
494
|
-
const airules = getDesktopFile("airules.txt")
|
495
|
-
return airules
|
496
|
-
}
|
497
|
-
|
498
|
-
return null
|
499
|
-
}
|
1
|
+
import Console from "./console"
|
2
|
+
|
3
|
+
const frontMatter = require("front-matter")
|
4
|
+
import * as MarkdownIt from "markdown-it"
|
5
|
+
import { syllable } from "syllable"
|
6
|
+
|
7
|
+
import * as path from "path"
|
8
|
+
import * as fs from "fs"
|
9
|
+
import { homedir } from "os"
|
10
|
+
import { join } from "path"
|
11
|
+
import { exec } from "child_process"
|
12
|
+
import { promisify } from "util"
|
13
|
+
|
14
|
+
import * as yaml from "js-yaml"
|
15
|
+
import * as prompts from "prompts"
|
16
|
+
|
17
|
+
type TEstimateReadingTimeReturns = {
|
18
|
+
minutes: number
|
19
|
+
words: number
|
20
|
+
}
|
21
|
+
|
22
|
+
export const estimateReadingTime = (
|
23
|
+
text: string,
|
24
|
+
wordsPerMinute = 150
|
25
|
+
): TEstimateReadingTimeReturns => {
|
26
|
+
const words = text.trim().split(/\s+/).length
|
27
|
+
const minutes = words / wordsPerMinute
|
28
|
+
|
29
|
+
if (minutes < 1) {
|
30
|
+
if (words === 0)
|
31
|
+
return {
|
32
|
+
minutes: 1,
|
33
|
+
words,
|
34
|
+
}
|
35
|
+
} else {
|
36
|
+
return {
|
37
|
+
minutes,
|
38
|
+
words,
|
39
|
+
}
|
40
|
+
}
|
41
|
+
|
42
|
+
return {
|
43
|
+
minutes: 1,
|
44
|
+
words,
|
45
|
+
}
|
46
|
+
}
|
47
|
+
|
48
|
+
export type PackageInfo = {
|
49
|
+
grading: string
|
50
|
+
difficulty: string
|
51
|
+
duration: number
|
52
|
+
description: {
|
53
|
+
us: string
|
54
|
+
}
|
55
|
+
title: {
|
56
|
+
us: string
|
57
|
+
}
|
58
|
+
}
|
59
|
+
|
60
|
+
type TFKGLResult = {
|
61
|
+
text: string
|
62
|
+
fkgl: number
|
63
|
+
}
|
64
|
+
|
65
|
+
export function checkReadability(
|
66
|
+
markdown: string,
|
67
|
+
wordsPerMinute = 200,
|
68
|
+
maxMinutes = 1
|
69
|
+
): {
|
70
|
+
newMarkdown: string
|
71
|
+
exceedsThreshold: boolean
|
72
|
+
minutes: number
|
73
|
+
body: string
|
74
|
+
fkglResult: TFKGLResult
|
75
|
+
// readingEase: number
|
76
|
+
} {
|
77
|
+
const parsed = frontMatter(markdown)
|
78
|
+
|
79
|
+
const fkglResult = fleschKincaidGrade(parsed.body)
|
80
|
+
|
81
|
+
const readingTime = estimateReadingTime(parsed.body, wordsPerMinute)
|
82
|
+
|
83
|
+
// const readingEase = estimateReadingEase(parsed.body)
|
84
|
+
let attributes = parsed.attributes ? parsed.attributes : {}
|
85
|
+
|
86
|
+
if (typeof parsed.attributes !== "object") {
|
87
|
+
attributes = {}
|
88
|
+
}
|
89
|
+
|
90
|
+
const updatedAttributes = {
|
91
|
+
...attributes,
|
92
|
+
readingTime,
|
93
|
+
fkglResult: fkglResult.fkgl,
|
94
|
+
}
|
95
|
+
|
96
|
+
let yamlFrontMatter = ""
|
97
|
+
try {
|
98
|
+
yamlFrontMatter = yaml.dump(updatedAttributes).trim()
|
99
|
+
} catch {
|
100
|
+
Console.error("Error dumping YAML front matter")
|
101
|
+
return {
|
102
|
+
newMarkdown: "",
|
103
|
+
exceedsThreshold: false,
|
104
|
+
minutes: 0,
|
105
|
+
body: "",
|
106
|
+
fkglResult,
|
107
|
+
// readingEase: 0,
|
108
|
+
}
|
109
|
+
}
|
110
|
+
|
111
|
+
const newMarkdown = `---\n${yamlFrontMatter}\n---\n\n${parsed.body}`
|
112
|
+
|
113
|
+
return {
|
114
|
+
newMarkdown,
|
115
|
+
exceedsThreshold: readingTime.minutes > maxMinutes,
|
116
|
+
minutes: readingTime.minutes,
|
117
|
+
body: parsed.body,
|
118
|
+
fkglResult,
|
119
|
+
}
|
120
|
+
}
|
121
|
+
|
122
|
+
export const slugify = (text: string) => {
|
123
|
+
return text
|
124
|
+
.toString()
|
125
|
+
.normalize("NFD")
|
126
|
+
.replace(/[\u0300-\u036F]/g, "")
|
127
|
+
.toLowerCase()
|
128
|
+
.trim()
|
129
|
+
.replace(/\s+/g, "-")
|
130
|
+
.replace(/[^\w-]+/g, "")
|
131
|
+
}
|
132
|
+
|
133
|
+
export const getExInfo = (title: string) => {
|
134
|
+
// Example title: '1.0 - Introduction to AI [READ: Small introduction to important concepts such as AI, machine learning, and their applications]'
|
135
|
+
let [exNumber, exTitle] = title.split(" - ")
|
136
|
+
|
137
|
+
// Extract kind and description
|
138
|
+
const kindMatch = exTitle.match(/\[(.*?):(.*?)]/)
|
139
|
+
const kind = kindMatch ? kindMatch[1].trim().toLowerCase() : "read"
|
140
|
+
const description = kindMatch ? kindMatch[2].trim() : ""
|
141
|
+
|
142
|
+
exNumber = exNumber.trim()
|
143
|
+
// Clean title
|
144
|
+
exTitle = exTitle.replace(kindMatch?.[0] || "", "").trim()
|
145
|
+
exTitle = slugify(exTitle)
|
146
|
+
|
147
|
+
return {
|
148
|
+
exNumber,
|
149
|
+
kind,
|
150
|
+
description,
|
151
|
+
exTitle,
|
152
|
+
}
|
153
|
+
}
|
154
|
+
|
155
|
+
export function extractImagesFromMarkdown(markdown: string) {
|
156
|
+
const imageRegex = /!\[([^\]]*)]\(([^)]+)\)/g
|
157
|
+
const images = []
|
158
|
+
let match
|
159
|
+
|
160
|
+
while ((match = imageRegex.exec(markdown)) !== null) {
|
161
|
+
const altText = match[1]
|
162
|
+
const url = match[2]
|
163
|
+
images.push({ alt: altText, url: url })
|
164
|
+
}
|
165
|
+
|
166
|
+
return images
|
167
|
+
}
|
168
|
+
|
169
|
+
export function getFilenameFromUrl(url: string) {
|
170
|
+
return path.basename(url)
|
171
|
+
}
|
172
|
+
|
173
|
+
export const makePackageInfo = (choices: any) => {
|
174
|
+
const packageInfo = {
|
175
|
+
grading: choices.grading,
|
176
|
+
difficulty: "beginner",
|
177
|
+
duration: 5,
|
178
|
+
description: {
|
179
|
+
us: choices.description,
|
180
|
+
},
|
181
|
+
title: {
|
182
|
+
us: choices.title,
|
183
|
+
},
|
184
|
+
slug: choices.title
|
185
|
+
.toLowerCase()
|
186
|
+
.replace(/ /g, "-")
|
187
|
+
.replace(/[^\w-]+/g, ""),
|
188
|
+
telemetry: {
|
189
|
+
batch: "https://breathecode.herokuapp.com/v1/assignment/me/telemetry",
|
190
|
+
},
|
191
|
+
}
|
192
|
+
return packageInfo
|
193
|
+
}
|
194
|
+
|
195
|
+
export function estimateDuration(listOfSteps: string[]): number {
|
196
|
+
let duration = 0
|
197
|
+
|
198
|
+
for (const step of listOfSteps) {
|
199
|
+
if (step.includes("[READ:")) {
|
200
|
+
duration += 1
|
201
|
+
} else if (step.includes("[QUIZ:")) {
|
202
|
+
duration += 2
|
203
|
+
} else if (step.includes("[CODE:")) {
|
204
|
+
duration += 3
|
205
|
+
}
|
206
|
+
}
|
207
|
+
|
208
|
+
return duration
|
209
|
+
}
|
210
|
+
|
211
|
+
const writeFilePromise = promisify(fs.writeFile)
|
212
|
+
const execPromise = promisify(exec)
|
213
|
+
|
214
|
+
export async function createFileOnDesktop(fileName: string, content: string) {
|
215
|
+
try {
|
216
|
+
const desktopPath = join(homedir(), "Desktop")
|
217
|
+
const filePath = join(desktopPath, fileName)
|
218
|
+
|
219
|
+
await writeFilePromise(filePath, content)
|
220
|
+
console.log(`File created successfully at: ${filePath}`)
|
221
|
+
|
222
|
+
await openFile(filePath)
|
223
|
+
} catch (error) {
|
224
|
+
console.error("Error:", error)
|
225
|
+
}
|
226
|
+
}
|
227
|
+
|
228
|
+
async function openFile(filePath: string) {
|
229
|
+
const platform = process.platform
|
230
|
+
let command
|
231
|
+
|
232
|
+
if (platform === "win32") {
|
233
|
+
command = `start "" "${filePath}"`
|
234
|
+
} else if (platform === "darwin") {
|
235
|
+
command = `open "${filePath}"`
|
236
|
+
} else {
|
237
|
+
command = `xdg-open "${filePath}"`
|
238
|
+
}
|
239
|
+
|
240
|
+
try {
|
241
|
+
await execPromise(command)
|
242
|
+
console.log("File opened successfully.")
|
243
|
+
} catch (error) {
|
244
|
+
console.error("Error opening the file:", error)
|
245
|
+
}
|
246
|
+
}
|
247
|
+
|
248
|
+
export function getDesktopFile(fileName: string) {
|
249
|
+
const desktopPath = join(homedir(), "Desktop")
|
250
|
+
const filePath = join(desktopPath, fileName)
|
251
|
+
|
252
|
+
const content = fs.readFileSync(filePath, "utf8")
|
253
|
+
// Delete the file after reading it
|
254
|
+
// fs.unlinkSync(filePath)
|
255
|
+
return content
|
256
|
+
}
|
257
|
+
|
258
|
+
// export function fleschKincaidReadingEase(text: string): number {
|
259
|
+
// const sentences = text.split(/[.!?]/).filter((s) => s.trim().length > 0)
|
260
|
+
// const words = text.split(/\s+/).filter((w) => w.trim().length > 0)
|
261
|
+
// const totalSyllables = words.reduce((sum, word) => sum + syllable(word), 0)
|
262
|
+
|
263
|
+
// const ASL = words.length / sentences.length // Average Sentence Length
|
264
|
+
// const ASW = totalSyllables / words.length // Average Syllables per Word
|
265
|
+
|
266
|
+
// return Math.round(206.835 - 1.015 * ASL - 84.6 * ASW)
|
267
|
+
// }
|
268
|
+
|
269
|
+
export function extractTextFromMarkdown(mdContent: string): string {
|
270
|
+
return mdContent
|
271
|
+
.replace(/!\[.*?]\(.*?\)/g, "") // Remove images
|
272
|
+
.replace(/\[.*?]\(.*?\)/g, "") // Remove links
|
273
|
+
.replace(/(```[\S\s]*?```|`.*?`)/g, "") // Remove inline & block code
|
274
|
+
.replace(/^#.*$/gm, "") // Remove headings
|
275
|
+
.replace(/(\*{1,2}|_{1,2})/g, "") // Remove bold/italic markers
|
276
|
+
.replace(/>\s?/g, "") // Remove blockquotes
|
277
|
+
.replace(/[*-]\s+/g, "") // Remove bullets from lists
|
278
|
+
.trim()
|
279
|
+
}
|
280
|
+
|
281
|
+
const cleanReadme = (readme: string) => {
|
282
|
+
// Replace <text> and </text> with nothing
|
283
|
+
return readme.replace(/<text>/g, "").replace(/<\/text>/g, "")
|
284
|
+
}
|
285
|
+
|
286
|
+
export const saveTranslatedReadme = async (
|
287
|
+
exercise: string,
|
288
|
+
languageCode: string,
|
289
|
+
readme: string
|
290
|
+
) => {
|
291
|
+
const readmePath = path.join(
|
292
|
+
process.cwd(),
|
293
|
+
"exercises",
|
294
|
+
exercise,
|
295
|
+
`README.${languageCode}.md`
|
296
|
+
)
|
297
|
+
fs.writeFileSync(readmePath, cleanReadme(readme))
|
298
|
+
}
|
299
|
+
|
300
|
+
/**
|
301
|
+
* Extracts paragraphs from a Markdown string.
|
302
|
+
* @param markdownText The input Markdown string.
|
303
|
+
* @returns An array of paragraph contents.
|
304
|
+
*/
|
305
|
+
export function extractParagraphs(markdownText: string): string[] {
|
306
|
+
const md = new MarkdownIt()
|
307
|
+
const tokens = md.parse(markdownText, {})
|
308
|
+
const paragraphs: string[] = []
|
309
|
+
|
310
|
+
for (let i = 0; i < tokens.length; i++) {
|
311
|
+
if (
|
312
|
+
tokens[i].type === "paragraph_open" &&
|
313
|
+
tokens[i + 1]?.type === "inline"
|
314
|
+
) {
|
315
|
+
paragraphs.push(tokens[i + 1].content)
|
316
|
+
}
|
317
|
+
}
|
318
|
+
|
319
|
+
return paragraphs
|
320
|
+
}
|
321
|
+
|
322
|
+
/**
|
323
|
+
* Splits a paragraph into words and separates each word into syllables.
|
324
|
+
* @param paragraph The input paragraph.
|
325
|
+
* @returns An array of words, each split into syllables.
|
326
|
+
*/
|
327
|
+
export function splitIntoSyllables(paragraph: string): string[] {
|
328
|
+
const words = paragraph.split(/\s+/)
|
329
|
+
const syllables: string[] = []
|
330
|
+
|
331
|
+
for (const word of words) {
|
332
|
+
syllables.push(...splitWordIntoSyllables(word))
|
333
|
+
}
|
334
|
+
|
335
|
+
return syllables
|
336
|
+
}
|
337
|
+
|
338
|
+
/**
|
339
|
+
* Splits a word into its syllables using a basic estimation.
|
340
|
+
* @param word The word to split.
|
341
|
+
* @returns An array of syllables.
|
342
|
+
*/
|
343
|
+
export function splitWordIntoSyllables(word: string): string[] {
|
344
|
+
const syllableCount = syllable(word)
|
345
|
+
|
346
|
+
// Simple heuristic: Split word into equal parts (not perfect, better with a dictionary-based approach)
|
347
|
+
if (syllableCount <= 1)
|
348
|
+
return [word]
|
349
|
+
|
350
|
+
const approxLength = Math.ceil(word.length / syllableCount)
|
351
|
+
const syllables: string[] = []
|
352
|
+
for (let i = 0; i < word.length; i += approxLength) {
|
353
|
+
// eslint-disable-next-line
|
354
|
+
syllables.push(word.substring(i, i + approxLength))
|
355
|
+
}
|
356
|
+
|
357
|
+
return syllables
|
358
|
+
}
|
359
|
+
|
360
|
+
/**
|
361
|
+
* Extracts words from a given paragraph.
|
362
|
+
* @param paragraph The input text.
|
363
|
+
* @returns An array of words.
|
364
|
+
*/
|
365
|
+
export function extractWords(paragraph: string): string[] {
|
366
|
+
const words = paragraph.match(/\b\w+\b/g) // Match words using regex
|
367
|
+
return words ? words : [] // Return words or an empty array if none found
|
368
|
+
}
|
369
|
+
|
370
|
+
/**
|
371
|
+
* Calculates the Flesch-Kincaid Grade Level (FKGL) for a given text.
|
372
|
+
* @param text The input paragraph.
|
373
|
+
* @returns The FKGL score.
|
374
|
+
*/
|
375
|
+
export function fleschKincaidGrade(text: string): TFKGLResult {
|
376
|
+
const processableText = extractTextFromMarkdown(text)
|
377
|
+
const words = extractWords(processableText)
|
378
|
+
const numWords = words.length
|
379
|
+
const numSentences = countSentences(processableText)
|
380
|
+
const numSyllables = words.reduce((total, word) => total + syllable(word), 0)
|
381
|
+
|
382
|
+
if (numWords === 0 || numSentences === 0) {
|
383
|
+
return {
|
384
|
+
text,
|
385
|
+
fkgl: 0,
|
386
|
+
}
|
387
|
+
}
|
388
|
+
|
389
|
+
const fkgl =
|
390
|
+
// eslint-disable-next-line
|
391
|
+
0.39 * (numWords / numSentences) + 11.8 * (numSyllables / numWords) - 15.59
|
392
|
+
|
393
|
+
return {
|
394
|
+
text,
|
395
|
+
fkgl: parseFloat(fkgl.toFixed(2)),
|
396
|
+
}
|
397
|
+
}
|
398
|
+
|
399
|
+
/**
|
400
|
+
* Counts the number of sentences in a given text.
|
401
|
+
* @param text The input paragraph.
|
402
|
+
* @returns The total number of sentences.
|
403
|
+
*/
|
404
|
+
export function countSentences(text: string): number {
|
405
|
+
const sentences = text
|
406
|
+
.split(/[!.?]+/)
|
407
|
+
.filter(sentence => sentence.trim().length > 0)
|
408
|
+
return sentences.length
|
409
|
+
}
|
410
|
+
|
411
|
+
export function howManyDifficultParagraphs(
|
412
|
+
paragraphs: TFKGLResult[],
|
413
|
+
maxFKGL: number
|
414
|
+
): number {
|
415
|
+
return paragraphs.filter(paragraph => paragraph.fkgl > maxFKGL).length
|
416
|
+
}
|
417
|
+
|
418
|
+
const example_content = `Write or paste your table of content below this line, each topic should be defined on a new line, here is an example:
|
419
|
+
|
420
|
+
Introduction to AI: Explain what is AI and its applications
|
421
|
+
Introduction to Machine Learning: Explain what is machine learning and its applications
|
422
|
+
What is an AI Model: Explain what is an AI model and its applications
|
423
|
+
How to use an AI Model: Different APIs, local models, etc.
|
424
|
+
How to build an AI Model: Fine-tuning, data collection, cleaning and more.
|
425
|
+
`
|
426
|
+
|
427
|
+
export const appendContentIndex = async () => {
|
428
|
+
const choices = await prompts([
|
429
|
+
{
|
430
|
+
type: "confirm",
|
431
|
+
name: "contentIndex",
|
432
|
+
message: "Do you have a content index for this tutorial?",
|
433
|
+
},
|
434
|
+
])
|
435
|
+
if (choices.contentIndex) {
|
436
|
+
await createFileOnDesktop("content_index.txt", example_content)
|
437
|
+
Console.info(
|
438
|
+
"Please make the necessary in the recently created file in your desktop, it should automatically open. Edit the file to match your expectations and save it. Keep the same name and structure as the example file. Continue when ready."
|
439
|
+
)
|
440
|
+
const isReady = await prompts([
|
441
|
+
{
|
442
|
+
type: "confirm",
|
443
|
+
name: "isReady",
|
444
|
+
message: "Are you ready to continue?",
|
445
|
+
},
|
446
|
+
])
|
447
|
+
if (!isReady.isReady) {
|
448
|
+
Console.error("Please make the necessary changes and try again.")
|
449
|
+
process.exit(1)
|
450
|
+
}
|
451
|
+
|
452
|
+
const contentIndex = getDesktopFile("content_index.txt")
|
453
|
+
return contentIndex
|
454
|
+
}
|
455
|
+
|
456
|
+
return null
|
457
|
+
}
|
458
|
+
|
459
|
+
const example_airules = `
|
460
|
+
Write with an engaging tone, use simple words and avoid complex sentences.
|
461
|
+
Write in first person, as if you are talking to the reader.
|
462
|
+
Add mental maps to help the reader understand the content.
|
463
|
+
Add diagrams to help the reader understand the content.
|
464
|
+
No code exercises required
|
465
|
+
|
466
|
+
`
|
467
|
+
|
468
|
+
export const appendAIRules = async () => {
|
469
|
+
const choices = await prompts([
|
470
|
+
{
|
471
|
+
type: "confirm",
|
472
|
+
name: "airules",
|
473
|
+
message:
|
474
|
+
"Do you want to add any specific rules for the AI? (e.g. no code exercises, no quizzes, etc, a particular writting style, etc)",
|
475
|
+
},
|
476
|
+
])
|
477
|
+
if (choices.airules) {
|
478
|
+
await createFileOnDesktop("airules.txt", example_airules)
|
479
|
+
Console.info(
|
480
|
+
"Please make the necessary in the recently created file in your desktop, it should automatically open. Edit the file to match your expectations and save it. Keep the same name and structure as the example file. Continue when ready."
|
481
|
+
)
|
482
|
+
const isReady = await prompts([
|
483
|
+
{
|
484
|
+
type: "confirm",
|
485
|
+
name: "isReady",
|
486
|
+
message: "Are you ready to continue?",
|
487
|
+
},
|
488
|
+
])
|
489
|
+
if (!isReady.isReady) {
|
490
|
+
Console.error("Please make the necessary changes and try again.")
|
491
|
+
process.exit(1)
|
492
|
+
}
|
493
|
+
|
494
|
+
const airules = getDesktopFile("airules.txt")
|
495
|
+
return airules
|
496
|
+
}
|
497
|
+
|
498
|
+
return null
|
499
|
+
}
|