@learnpack/learnpack 5.0.33 → 5.0.35

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@learnpack/learnpack",
3
3
  "description": "Seamlessly build, sell and/or take interactive & auto-graded tutorials, start learning now or build a new tutorial to your audience.",
4
- "version": "5.0.33",
4
+ "version": "5.0.35",
5
5
  "author": "Alejandro Sanchez @alesanchezr",
6
6
  "contributors": [
7
7
  {
@@ -42,6 +42,7 @@
42
42
  "express": "^4.17.1",
43
43
  "front-matter": "^4.0.2",
44
44
  "js-yaml": "^4.1.0",
45
+ "markdown-it": "^14.1.0",
45
46
  "moment": "^2.27.0",
46
47
  "node-emoji": "^1.10.0",
47
48
  "node-fetch": "^2.7.0",
@@ -49,6 +50,7 @@
49
50
  "prompts": "^2.3.2",
50
51
  "shelljs": "^0.8.4",
51
52
  "socket.io": "^4.4.1",
53
+ "syllable": "^5.0.1",
52
54
  "targz": "^1.0.1",
53
55
  "text-readability": "^1.1.0",
54
56
  "tslib": "^1",
@@ -63,6 +65,7 @@
63
65
  "@types/express": "^4.17.13",
64
66
  "@types/fs-extra": "^8.1.0",
65
67
  "@types/js-yaml": "^4.0.9",
68
+ "@types/markdown-it": "^14.1.2",
66
69
  "@types/mocha": "^5",
67
70
  "@types/mock-fs": "^4.13.1",
68
71
  "@types/node": "^22.13.5",
@@ -1,36 +1,67 @@
1
- import { flags } from "@oclif/command"
2
- import BaseCommand from "../utils/BaseCommand"
3
- // eslint-disable-next-line
4
- import * as fs from "fs-extra"
5
- import Console from "../utils/console"
6
-
7
- import SessionManager from "../managers/session"
8
- import { createFileOnDesktop, getContentIndex } from "../utils/creatorUtilities"
9
-
10
- class BreakTokenCommand extends BaseCommand {
11
- static description = "Break the token"
12
-
13
- static flags = {
14
- ...BaseCommand.flags,
15
- grading: flags.help({ char: "h" }),
16
- }
17
-
18
- async run() {
19
- const { flags } = this.parse(BreakTokenCommand)
20
-
21
- // await SessionManager.breakToken()
22
- await createFileOnDesktop()
23
- Console.info(
24
- "File created on desktop, please make the necessary changes and continue when ready"
25
- )
26
-
27
- // Wait for user to press enter
28
- process.stdin.once("data", () => {
29
- Console.info("File content:")
30
- Console.info(getContentIndex())
31
- process.exit(0)
32
- })
33
- }
34
- }
35
-
36
- export default BreakTokenCommand
1
+ import { flags } from "@oclif/command"
2
+ import BaseCommand from "../utils/BaseCommand"
3
+
4
+ import Console from "../utils/console"
5
+
6
+ import {
7
+ extractParagraphs,
8
+ splitIntoSyllables,
9
+ extractWords,
10
+ countSentences,
11
+ fleschKincaidGrade,
12
+ } from "../utils/creatorUtilities"
13
+
14
+ const exampleMd = `# How to Install Node.js
15
+
16
+ Node.js lets you run JavaScript outside a web browser.
17
+
18
+ ## Step 1: Download Node.js
19
+
20
+ Get the Node.js installer from the [official site](https://nodejs.org/en/download/).
21
+
22
+ ## Step 2: Install Node.js
23
+
24
+ Open the installer and follow the steps to finish.
25
+
26
+ ## Step 3: Verify the Installation
27
+
28
+ Open a terminal and type:
29
+
30
+ \`\`\`bash
31
+ node -v
32
+ \`\`\`
33
+ `
34
+
35
+ class BreakTokenCommand extends BaseCommand {
36
+ static description = "Break the token"
37
+
38
+ static flags = {
39
+ ...BaseCommand.flags,
40
+ grading: flags.help({ char: "h" }),
41
+ }
42
+
43
+ async run() {
44
+ const { flags } = this.parse(BreakTokenCommand)
45
+
46
+ // await SessionManager.breakToken()
47
+ const paragraphs = extractParagraphs(exampleMd)
48
+ for (const paragraph of paragraphs) {
49
+ const syllables = splitIntoSyllables(paragraph)
50
+ const words = extractWords(paragraph)
51
+ const sentences = countSentences(paragraph)
52
+ const fkgl = fleschKincaidGrade(paragraph)
53
+ Console.info(paragraph)
54
+ Console.info(`Number of syllables: ${syllables.length}`)
55
+ Console.info(syllables)
56
+ Console.info(`Number of words: ${words.length}`)
57
+ Console.info(words)
58
+ Console.info(`Number of sentences: ${sentences}`)
59
+ Console.info(`FKGL: ${fkgl}`)
60
+ Console.info("---")
61
+ }
62
+
63
+ process.exit(0)
64
+ }
65
+ }
66
+
67
+ export default BreakTokenCommand
@@ -19,12 +19,12 @@ import {
19
19
  createCodeFile,
20
20
  readmeCreator,
21
21
  createPreviewReadme,
22
- reduceReadme,
22
+ makeReadmeReadable,
23
23
  isValidRigoToken,
24
24
  } from "../utils/rigoActions"
25
25
  import { getConsumable } from "../utils/api"
26
26
  import {
27
- checkReadingTime,
27
+ checkReadability,
28
28
  PackageInfo,
29
29
  getExInfo,
30
30
  extractImagesFromMarkdown,
@@ -42,6 +42,13 @@ const durationByKind: Record<string, number> = {
42
42
  read: 1,
43
43
  }
44
44
 
45
+ const PARAMS = {
46
+ expected_grade_level: "6",
47
+ max_fkgl: 8,
48
+ max_words: 200,
49
+ max_rewrite_attempts: 3,
50
+ }
51
+
45
52
  const whichTargetAudience = async () => {
46
53
  const res = await prompts([
47
54
  {
@@ -53,6 +60,94 @@ const whichTargetAudience = async () => {
53
60
  return res.targetAudience
54
61
  }
55
62
 
63
+ async function processExercise(
64
+ rigoToken: string,
65
+ steps: string[],
66
+ packageContext: string,
67
+ exercise: any,
68
+ exercisesDir: string
69
+ ): Promise<string> {
70
+ const { exNumber, exTitle, kind, description } = getExInfo(exercise)
71
+ const exerciseDir = path.join(exercisesDir, `${exNumber}-${exTitle}`)
72
+
73
+ const readme = await readmeCreator(rigoToken, {
74
+ title: `${exNumber} - ${exTitle}`,
75
+ output_lang: "en",
76
+ list_of_exercises: steps.join(","),
77
+ tutorial_description: packageContext,
78
+ lesson_description: description,
79
+ kind: kind.toLowerCase(),
80
+ })
81
+
82
+ const duration = durationByKind[kind.toLowerCase()]
83
+ let attempts = 0
84
+ let readability = checkReadability(readme.parsed.content, 200, duration || 1)
85
+
86
+ while (
87
+ readability.fkglResult.fkgl > PARAMS.max_fkgl &&
88
+ attempts < PARAMS.max_rewrite_attempts
89
+ ) {
90
+ Console.warning(
91
+ `The lesson ${exTitle} has as readability score of ${
92
+ readability.fkglResult.fkgl
93
+ } . It exceeds the maximum of words per minute. Rewriting it... (Attempt ${
94
+ attempts + 1
95
+ })`
96
+ )
97
+
98
+ // eslint-disable-next-line
99
+ const reducedReadme = await makeReadmeReadable(rigoToken, {
100
+ lesson: readability.body,
101
+ number_of_words: readability.minutes.toString(),
102
+ expected_number_words: PARAMS.max_words.toString(),
103
+ fkgl_results: JSON.stringify(readability.fkglResult),
104
+ expected_grade_level: PARAMS.expected_grade_level,
105
+ })
106
+
107
+ if (!reducedReadme)
108
+ break
109
+
110
+ readability = checkReadability(
111
+ reducedReadme.parsed.content,
112
+ PARAMS.max_words,
113
+ duration || 1
114
+ )
115
+
116
+ attempts++
117
+ }
118
+
119
+ Console.success(
120
+ `After ${attempts} attempts, the lesson ${exTitle} has a readability score of ${
121
+ readability.fkglResult.fkgl
122
+ } using FKGL. And it has ${readability.minutes.toFixed(
123
+ 2
124
+ )} minutes of reading time.`
125
+ )
126
+
127
+ const readmeFilename = "README.md"
128
+ fs.writeFileSync(
129
+ path.join(exerciseDir, readmeFilename),
130
+ readability.newMarkdown
131
+ )
132
+
133
+ if (kind.toLowerCase() === "code") {
134
+ const codeFile = await createCodeFile(rigoToken, {
135
+ readme: readability.newMarkdown,
136
+ tutorial_info: packageContext,
137
+ })
138
+
139
+ fs.writeFileSync(
140
+ path.join(
141
+ exerciseDir,
142
+ `app.${codeFile.parsed.extension.replace(".", "")}`
143
+ ),
144
+ codeFile.parsed.content
145
+ )
146
+ }
147
+
148
+ return readability.newMarkdown
149
+ }
150
+
56
151
  const initializeInteractiveCreation = async (
57
152
  rigoToken: string,
58
153
  courseInfo: string
@@ -252,69 +347,9 @@ const handleAILogic = async (tutorialDir: string, packageInfo: PackageInfo) => {
252
347
  fs.ensureDirSync(exerciseDir)
253
348
  }
254
349
 
255
- const exercisePromises = steps.map(async (exercise: any, index: number) => {
256
- const { exNumber, exTitle, kind, description } = getExInfo(exercise)
257
- const exerciseDir = path.join(exercisesDir, `${exNumber}-${exTitle}`)
258
-
259
- const readme = await readmeCreator(rigoToken, {
260
- title: `${exNumber} - ${exTitle}`,
261
- output_lang: "en",
262
- list_of_exercises: steps.join(","),
263
- tutorial_description: packageContext,
264
- lesson_description: description,
265
- kind: kind.toLowerCase(),
266
- })
267
-
268
- const duration = durationByKind[kind.toLowerCase()]
269
- let readingTime = checkReadingTime(
270
- readme.parsed.content,
271
- 200,
272
- duration || 1
273
- )
274
-
275
- if (readingTime.exceedsThreshold) {
276
- // Console.info(
277
- // `The reading time for the lesson ${exTitle} exceeds the threshold, reducing it...`
278
- // )
279
- const reducedReadme = await reduceReadme(rigoToken, {
280
- lesson: readingTime.body,
281
- number_of_words: readingTime.minutes.toString(),
282
- expected_number_words: "200",
283
- })
284
-
285
- if (reducedReadme) {
286
- readingTime = checkReadingTime(
287
- reducedReadme.parsed.content,
288
- 200,
289
- duration || 1
290
- )
291
- }
292
- }
293
-
294
- const readmeFilename = "README.md"
295
-
296
- fs.writeFileSync(
297
- path.join(exerciseDir, readmeFilename),
298
- readingTime.newMarkdown
299
- )
300
-
301
- if (kind.toLowerCase() === "code") {
302
- const codeFile = await createCodeFile(rigoToken, {
303
- readme: readme.parsed.content,
304
- tutorial_info: packageContext,
305
- })
306
-
307
- fs.writeFileSync(
308
- path.join(
309
- exerciseDir,
310
- `app.${codeFile.parsed.extension.replace(".", "")}`
311
- ),
312
- codeFile.parsed.content
313
- )
314
- }
315
-
316
- return readingTime.newMarkdown
317
- })
350
+ const exercisePromises = steps.map(exercise =>
351
+ processExercise(rigoToken, steps, packageContext, exercise, exercisesDir)
352
+ )
318
353
 
319
354
  const readmeContents = await Promise.all(exercisePromises)
320
355
  Console.success("Lessons created! 🎉")
@@ -9,7 +9,7 @@ import { IFile } from "../../models/file"
9
9
  import { IExercise } from "../../models/exercise-obj"
10
10
 
11
11
  // eslint-disable-next-line
12
- const frontMatter = require("front-matter");
12
+ const frontMatter = require("front-matter")
13
13
 
14
14
  export const exercise = (
15
15
  path: string,
@@ -140,6 +140,23 @@ continue
140
140
 
141
141
  return content
142
142
  },
143
+
144
+ renameFolder: function (newName: string) {
145
+ if (!config?.dirPath) {
146
+ throw new Error("No config directory found")
147
+ }
148
+
149
+ try {
150
+ const newPath = p.join(config?.exercisesPath, newName)
151
+ fs.renameSync(this.path, newPath)
152
+ this.path = newPath
153
+ this.slug = newName
154
+ this.title = newName
155
+ } catch (error) {
156
+ console.log(error)
157
+ throw new Error("Failed to rename exercise: " + error)
158
+ }
159
+ },
143
160
  saveFile: function (name: string, content: string) {
144
161
  const file: IFile | undefined = this.files.find(
145
162
  (f: IFile) => f.name === name
@@ -453,6 +453,32 @@ throw new Error("File not found: " + filePath)
453
453
  })
454
454
  )
455
455
 
456
+ app.put(
457
+ "/actions/rename",
458
+ jsonBodyParser,
459
+ withHandler(async (req: express.Request, res: express.Response) => {
460
+ const { slug, newSlug } = req.body
461
+ const exercise = configManager.getExercise(slug)
462
+ if (!exercise) {
463
+ return res.status(400).json({ error: "Exercise not found" })
464
+ }
465
+
466
+ try {
467
+ if (exercise.renameFolder) {
468
+ exercise.renameFolder(newSlug)
469
+ res.json({ status: "ok" })
470
+ } else {
471
+ res.status(500).json({
472
+ error:
473
+ "Failed to rename exercise because it's not supported by the exercise",
474
+ })
475
+ }
476
+ } catch {
477
+ res.status(500).json({ error: "Failed to rename exercise" })
478
+ }
479
+ })
480
+ )
481
+
456
482
  app.post(
457
483
  "/exercise/:slug/create",
458
484
  jsonBodyParser,
@@ -1,29 +1,30 @@
1
- import { IFile } from "./file"
2
- import { IConfig } from "./config"
3
- import { ISocket } from "./socket"
4
-
5
- export interface IExercise {
6
- position?: number;
7
- files: Array<IFile>;
8
- slug: string;
9
- path: string;
10
- done: boolean;
11
- language?: string | null;
12
- entry?: string | null;
13
- graded?: boolean;
14
- translations?: { [key: string]: string };
15
- title: string;
16
- getReadme?: (lang: string | null) => any;
17
- getFile?: (name: string) => string | Buffer;
18
- saveFile?: (name: string, content: string) => void;
19
- getTestReport?: () => any;
20
- test?: (sessionConfig: any, config: IConfig, socket: ISocket) => void;
21
- }
22
-
23
- export interface IExerciseData {
24
- lastMessages?: any;
25
- userMessage?: string;
26
- exerciseSlug: string;
27
- entryPoint?: string;
28
- files: string[];
29
- }
1
+ import { IFile } from "./file"
2
+ import { IConfig } from "./config"
3
+ import { ISocket } from "./socket"
4
+
5
+ export interface IExercise {
6
+ position?: number
7
+ files: Array<IFile>
8
+ slug: string
9
+ path: string
10
+ done: boolean
11
+ language?: string | null
12
+ entry?: string | null
13
+ graded?: boolean
14
+ translations?: { [key: string]: string }
15
+ title: string
16
+ getReadme?: (lang: string | null) => any
17
+ getFile?: (name: string) => string | Buffer
18
+ saveFile?: (name: string, content: string) => void
19
+ renameFolder?: (newName: string) => void
20
+ getTestReport?: () => any
21
+ test?: (sessionConfig: any, config: IConfig, socket: ISocket) => void
22
+ }
23
+
24
+ export interface IExerciseData {
25
+ lastMessages?: any
26
+ userMessage?: string
27
+ exerciseSlug: string
28
+ entryPoint?: string
29
+ files: string[]
30
+ }
@@ -1,5 +1,8 @@
1
+ import Console from "./console"
1
2
 
2
3
  const frontMatter = require("front-matter")
4
+ import * as MarkdownIt from "markdown-it"
5
+ import { syllable } from "syllable"
3
6
 
4
7
  import * as path from "path"
5
8
  import * as fs from "fs"
@@ -53,7 +56,12 @@ export type PackageInfo = {
53
56
  }
54
57
  }
55
58
 
56
- export function checkReadingTime(
59
+ type TFKGLResult = {
60
+ text: string
61
+ fkgl: number
62
+ }
63
+
64
+ export function checkReadability(
57
65
  markdown: string,
58
66
  wordsPerMinute = 200,
59
67
  maxMinutes = 1
@@ -62,10 +70,13 @@ export function checkReadingTime(
62
70
  exceedsThreshold: boolean
63
71
  minutes: number
64
72
  body: string
73
+ fkglResult: TFKGLResult
65
74
  // readingEase: number
66
75
  } {
67
76
  const parsed = frontMatter(markdown)
68
77
 
78
+ const fkglResult = fleschKincaidGrade(parsed.body)
79
+
69
80
  const readingTime = estimateReadingTime(parsed.body, wordsPerMinute)
70
81
 
71
82
  // const readingEase = estimateReadingEase(parsed.body)
@@ -78,23 +89,24 @@ export function checkReadingTime(
78
89
  const updatedAttributes = {
79
90
  ...attributes,
80
91
  readingTime,
81
- // readingEase,
92
+ fkglResult: fkglResult.fkgl,
82
93
  }
83
94
 
84
95
  let yamlFrontMatter = ""
85
96
  try {
86
97
  yamlFrontMatter = yaml.dump(updatedAttributes).trim()
87
98
  } catch {
99
+ Console.error("Error dumping YAML front matter")
88
100
  return {
89
101
  newMarkdown: "",
90
102
  exceedsThreshold: false,
91
103
  minutes: 0,
92
104
  body: "",
105
+ fkglResult,
93
106
  // readingEase: 0,
94
107
  }
95
108
  }
96
109
 
97
- // Reconstruct the markdown with the front matter
98
110
  const newMarkdown = `---\n${yamlFrontMatter}\n---\n\n${parsed.body}`
99
111
 
100
112
  return {
@@ -102,7 +114,7 @@ export function checkReadingTime(
102
114
  exceedsThreshold: readingTime.minutes > maxMinutes,
103
115
  minutes: readingTime.minutes,
104
116
  body: parsed.body,
105
- // readingEase: 0,
117
+ fkglResult,
106
118
  }
107
119
  }
108
120
 
@@ -260,27 +272,17 @@ export function getContentIndex() {
260
272
  // }
261
273
 
262
274
  export function extractTextFromMarkdown(mdContent: string): string {
263
- let content = mdContent.replace(/!\[.*?]\(.*?\)/g, "")
264
- content = content.replace(/\[.*?]\(.*?\)/g, "")
265
- content = content.replace(/`.*?`/g, "")
266
- content = content.replace(/```[\S\s]*?```/g, "")
267
-
268
- return content.trim()
275
+ return mdContent
276
+ .replace(/!\[.*?]\(.*?\)/g, "") // Remove images
277
+ .replace(/\[.*?]\(.*?\)/g, "") // Remove links
278
+ .replace(/(```[\S\s]*?```|`.*?`)/g, "") // Remove inline & block code
279
+ .replace(/^#.*$/gm, "") // Remove headings
280
+ .replace(/(\*{1,2}|_{1,2})/g, "") // Remove bold/italic markers
281
+ .replace(/>\s?/g, "") // Remove blockquotes
282
+ .replace(/[*-]\s+/g, "") // Remove bullets from lists
283
+ .trim()
269
284
  }
270
285
 
271
- // export const estimateReadingEase = async (text: string): Promise<number> => {
272
- // const cleanedText = extractTextFromMarkdown(text)
273
- // // @ts-ignore
274
- // const rs = (await import("text-readability")).default;
275
- // // return fleschKincaidReadingEase(cleanedText)
276
- // // const score = readability(cleanedText)
277
- // // console.log(score)
278
- // // return score.fleschKincaid ?? 0
279
- // const score = rs.fleschReadingEase(cleanedText)
280
- // console.log(score, "SCORE FLESCH READING EASE")
281
- // return score
282
- // }
283
-
284
286
  const cleanReadme = (readme: string) => {
285
287
  // Replace <text> and </text> with nothing
286
288
  return readme.replace(/<text>/g, "").replace(/<\/text>/g, "")
@@ -299,3 +301,121 @@ export const saveTranslatedReadme = async (
299
301
  )
300
302
  fs.writeFileSync(readmePath, cleanReadme(readme))
301
303
  }
304
+
305
+ /**
306
+ * Extracts paragraphs from a Markdown string.
307
+ * @param markdownText The input Markdown string.
308
+ * @returns An array of paragraph contents.
309
+ */
310
+ export function extractParagraphs(markdownText: string): string[] {
311
+ const md = new MarkdownIt()
312
+ const tokens = md.parse(markdownText, {})
313
+ const paragraphs: string[] = []
314
+
315
+ for (let i = 0; i < tokens.length; i++) {
316
+ if (
317
+ tokens[i].type === "paragraph_open" &&
318
+ tokens[i + 1]?.type === "inline"
319
+ ) {
320
+ paragraphs.push(tokens[i + 1].content)
321
+ }
322
+ }
323
+
324
+ return paragraphs
325
+ }
326
+
327
+ /**
328
+ * Splits a paragraph into words and separates each word into syllables.
329
+ * @param paragraph The input paragraph.
330
+ * @returns An array of words, each split into syllables.
331
+ */
332
+ export function splitIntoSyllables(paragraph: string): string[] {
333
+ const words = paragraph.split(/\s+/)
334
+ const syllables: string[] = []
335
+
336
+ for (const word of words) {
337
+ syllables.push(...splitWordIntoSyllables(word))
338
+ }
339
+
340
+ return syllables
341
+ }
342
+
343
+ /**
344
+ * Splits a word into its syllables using a basic estimation.
345
+ * @param word The word to split.
346
+ * @returns An array of syllables.
347
+ */
348
+ export function splitWordIntoSyllables(word: string): string[] {
349
+ const syllableCount = syllable(word)
350
+
351
+ // Simple heuristic: Split word into equal parts (not perfect, better with a dictionary-based approach)
352
+ if (syllableCount <= 1)
353
+ return [word]
354
+
355
+ const approxLength = Math.ceil(word.length / syllableCount)
356
+ const syllables: string[] = []
357
+ for (let i = 0; i < word.length; i += approxLength) {
358
+ // eslint-disable-next-line
359
+ syllables.push(word.substring(i, i + approxLength))
360
+ }
361
+
362
+ return syllables
363
+ }
364
+
365
+ /**
366
+ * Extracts words from a given paragraph.
367
+ * @param paragraph The input text.
368
+ * @returns An array of words.
369
+ */
370
+ export function extractWords(paragraph: string): string[] {
371
+ const words = paragraph.match(/\b\w+\b/g) // Match words using regex
372
+ return words ? words : [] // Return words or an empty array if none found
373
+ }
374
+
375
+ /**
376
+ * Calculates the Flesch-Kincaid Grade Level (FKGL) for a given text.
377
+ * @param text The input paragraph.
378
+ * @returns The FKGL score.
379
+ */
380
+ export function fleschKincaidGrade(text: string): TFKGLResult {
381
+ const processableText = extractTextFromMarkdown(text)
382
+ const words = extractWords(processableText)
383
+ const numWords = words.length
384
+ const numSentences = countSentences(processableText)
385
+ const numSyllables = words.reduce((total, word) => total + syllable(word), 0)
386
+
387
+ if (numWords === 0 || numSentences === 0) {
388
+ return {
389
+ text,
390
+ fkgl: 0,
391
+ }
392
+ }
393
+
394
+ const fkgl =
395
+ // eslint-disable-next-line
396
+ 0.39 * (numWords / numSentences) + 11.8 * (numSyllables / numWords) - 15.59
397
+
398
+ return {
399
+ text,
400
+ fkgl: parseFloat(fkgl.toFixed(2)),
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Counts the number of sentences in a given text.
406
+ * @param text The input paragraph.
407
+ * @returns The total number of sentences.
408
+ */
409
+ export function countSentences(text: string): number {
410
+ const sentences = text
411
+ .split(/[!.?]+/)
412
+ .filter(sentence => sentence.trim().length > 0)
413
+ return sentences.length
414
+ }
415
+
416
+ export function howManyDifficultParagraphs(
417
+ paragraphs: TFKGLResult[],
418
+ maxFKGL: number
419
+ ): number {
420
+ return paragraphs.filter(paragraph => paragraph.fkgl > maxFKGL).length
421
+ }