@lodashventure/medusa-product-content 1.1.0

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.
@@ -0,0 +1,384 @@
1
+ "use strict";
2
+ /**
3
+ * Import/Export utility for specifications
4
+ */
5
+ var __importDefault = (this && this.__importDefault) || function (mod) {
6
+ return (mod && mod.__esModule) ? mod : { "default": mod };
7
+ };
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.exportSpecsToCSV = exportSpecsToCSV;
10
+ exports.exportSpecsToJSON = exportSpecsToJSON;
11
+ exports.importSpecsFromCSV = importSpecsFromCSV;
12
+ exports.importSpecsFromJSON = importSpecsFromJSON;
13
+ exports.mergeSpecs = mergeSpecs;
14
+ const papaparse_1 = __importDefault(require("papaparse"));
15
+ /**
16
+ * Export specifications to CSV
17
+ */
18
+ function exportSpecsToCSV(specs) {
19
+ const rows = [];
20
+ const locales = new Set();
21
+ // Collect all locales
22
+ specs.groups.forEach(group => {
23
+ Object.keys(group.i18n).forEach(locale => locales.add(locale));
24
+ group.items.forEach(item => {
25
+ Object.keys(item.i18n).forEach(locale => locales.add(locale));
26
+ });
27
+ });
28
+ const localeArray = Array.from(locales).sort();
29
+ // Create headers
30
+ const headers = [
31
+ 'group_key',
32
+ 'group_position',
33
+ ...localeArray.map(locale => `group_label.${locale}`),
34
+ 'item_position',
35
+ ...localeArray.flatMap(locale => [`key.${locale}`, `value.${locale}`]),
36
+ 'visible'
37
+ ];
38
+ // Create data rows
39
+ specs.groups.forEach(group => {
40
+ group.items.forEach(item => {
41
+ const row = {
42
+ group_key: group.key,
43
+ group_position: group.position,
44
+ item_position: item.position,
45
+ visible: item.visible ? 'true' : 'false'
46
+ };
47
+ // Add group labels
48
+ localeArray.forEach(locale => {
49
+ row[`group_label.${locale}`] = group.i18n[locale]?.label || '';
50
+ });
51
+ // Add item keys and values
52
+ localeArray.forEach(locale => {
53
+ row[`key.${locale}`] = item.i18n[locale]?.key || '';
54
+ row[`value.${locale}`] = item.i18n[locale]?.value || '';
55
+ });
56
+ rows.push(row);
57
+ });
58
+ });
59
+ // Convert to CSV
60
+ const csv = papaparse_1.default.unparse({
61
+ fields: headers,
62
+ data: rows
63
+ });
64
+ return csv;
65
+ }
66
+ /**
67
+ * Export specifications to JSON
68
+ */
69
+ function exportSpecsToJSON(specs) {
70
+ return JSON.stringify(specs, null, 2);
71
+ }
72
+ /**
73
+ * Import specifications from CSV
74
+ */
75
+ async function importSpecsFromCSV(csvContent, defaultLocale = 'th-TH') {
76
+ const parseResult = papaparse_1.default.parse(csvContent, {
77
+ header: true,
78
+ skipEmptyLines: true
79
+ });
80
+ if (parseResult.errors.length > 0) {
81
+ return {
82
+ specs: null,
83
+ validation: {
84
+ isValid: false,
85
+ errors: parseResult.errors.map((error, index) => ({
86
+ row: error.row || index,
87
+ field: 'parse',
88
+ message: error.message,
89
+ value: error.code
90
+ }))
91
+ }
92
+ };
93
+ }
94
+ const rows = parseResult.data;
95
+ const validation = validateCSVData(rows);
96
+ if (!validation.isValid) {
97
+ return { specs: null, validation };
98
+ }
99
+ // Convert CSV rows to specs structure
100
+ const specs = convertCSVToSpecs(rows, defaultLocale);
101
+ return {
102
+ specs,
103
+ validation: {
104
+ isValid: true,
105
+ errors: [],
106
+ processedRows: rows.length
107
+ }
108
+ };
109
+ }
110
+ /**
111
+ * Import specifications from JSON
112
+ */
113
+ async function importSpecsFromJSON(jsonContent) {
114
+ try {
115
+ const specs = JSON.parse(jsonContent);
116
+ // Validate structure
117
+ const validation = validateSpecsStructure(specs);
118
+ if (!validation.isValid) {
119
+ return { specs: null, validation };
120
+ }
121
+ return {
122
+ specs,
123
+ validation: {
124
+ isValid: true,
125
+ errors: [],
126
+ processedRows: specs.groups.reduce((acc, g) => acc + g.items.length, 0)
127
+ }
128
+ };
129
+ }
130
+ catch (error) {
131
+ return {
132
+ specs: null,
133
+ validation: {
134
+ isValid: false,
135
+ errors: [{
136
+ row: 0,
137
+ field: 'json',
138
+ message: `Invalid JSON: ${error.message}`
139
+ }]
140
+ }
141
+ };
142
+ }
143
+ }
144
+ /**
145
+ * Validate CSV data
146
+ */
147
+ function validateCSVData(rows) {
148
+ const errors = [];
149
+ rows.forEach((row, index) => {
150
+ // Check required fields
151
+ if (!row.group_key) {
152
+ errors.push({
153
+ row: index + 1,
154
+ field: 'group_key',
155
+ message: 'Group key is required',
156
+ value: row.group_key
157
+ });
158
+ }
159
+ // Check at least one key/value pair exists
160
+ const hasContent = Object.keys(row).some(key => {
161
+ if (key.startsWith('key.') || key.startsWith('value.')) {
162
+ return row[key] && row[key].trim() !== '';
163
+ }
164
+ return false;
165
+ });
166
+ if (!hasContent) {
167
+ errors.push({
168
+ row: index + 1,
169
+ field: 'content',
170
+ message: 'At least one key/value pair is required'
171
+ });
172
+ }
173
+ // Validate visible field
174
+ if (row.visible && !['true', 'false', '1', '0', ''].includes(row.visible.toLowerCase())) {
175
+ errors.push({
176
+ row: index + 1,
177
+ field: 'visible',
178
+ message: 'Visible must be true or false',
179
+ value: row.visible
180
+ });
181
+ }
182
+ });
183
+ return {
184
+ isValid: errors.length === 0,
185
+ errors,
186
+ processedRows: rows.length
187
+ };
188
+ }
189
+ /**
190
+ * Validate specs structure
191
+ */
192
+ function validateSpecsStructure(specs) {
193
+ const errors = [];
194
+ if (!specs || typeof specs !== 'object') {
195
+ errors.push({
196
+ row: 0,
197
+ field: 'root',
198
+ message: 'Invalid specs structure'
199
+ });
200
+ return { isValid: false, errors };
201
+ }
202
+ if (!specs.default_locale) {
203
+ errors.push({
204
+ row: 0,
205
+ field: 'default_locale',
206
+ message: 'Default locale is required'
207
+ });
208
+ }
209
+ if (!Array.isArray(specs.groups)) {
210
+ errors.push({
211
+ row: 0,
212
+ field: 'groups',
213
+ message: 'Groups must be an array'
214
+ });
215
+ return { isValid: false, errors };
216
+ }
217
+ specs.groups.forEach((group, groupIndex) => {
218
+ if (!group.key) {
219
+ errors.push({
220
+ row: groupIndex,
221
+ field: 'group.key',
222
+ message: 'Group key is required'
223
+ });
224
+ }
225
+ if (!group.i18n || typeof group.i18n !== 'object') {
226
+ errors.push({
227
+ row: groupIndex,
228
+ field: 'group.i18n',
229
+ message: 'Group i18n is required'
230
+ });
231
+ }
232
+ if (!Array.isArray(group.items)) {
233
+ errors.push({
234
+ row: groupIndex,
235
+ field: 'group.items',
236
+ message: 'Group items must be an array'
237
+ });
238
+ }
239
+ else {
240
+ group.items.forEach((item, itemIndex) => {
241
+ if (!item.i18n || typeof item.i18n !== 'object') {
242
+ errors.push({
243
+ row: groupIndex * 100 + itemIndex,
244
+ field: 'item.i18n',
245
+ message: 'Item i18n is required'
246
+ });
247
+ }
248
+ });
249
+ }
250
+ });
251
+ return {
252
+ isValid: errors.length === 0,
253
+ errors
254
+ };
255
+ }
256
+ /**
257
+ * Convert CSV rows to specs structure
258
+ */
259
+ function convertCSVToSpecs(rows, defaultLocale) {
260
+ const groupsMap = new Map();
261
+ const locales = new Set();
262
+ // Collect all locales from column names
263
+ Object.keys(rows[0] || {}).forEach(key => {
264
+ const match = key.match(/\.([\w-]+)$/);
265
+ if (match) {
266
+ locales.add(match[1]);
267
+ }
268
+ });
269
+ rows.forEach(row => {
270
+ const groupKey = row.group_key;
271
+ let group = groupsMap.get(groupKey);
272
+ if (!group) {
273
+ group = {
274
+ key: groupKey,
275
+ position: parseInt(row.group_position) || groupsMap.size + 1,
276
+ i18n: {},
277
+ items: []
278
+ };
279
+ // Add group labels
280
+ locales.forEach(locale => {
281
+ const label = row[`group_label.${locale}`];
282
+ if (label) {
283
+ group.i18n[locale] = { label };
284
+ }
285
+ });
286
+ groupsMap.set(groupKey, group);
287
+ }
288
+ // Create item
289
+ const item = {
290
+ position: parseInt(row.item_position) || group.items.length + 1,
291
+ i18n: {},
292
+ visible: row.visible !== 'false' && row.visible !== '0'
293
+ };
294
+ // Add item translations
295
+ locales.forEach(locale => {
296
+ const key = row[`key.${locale}`];
297
+ const value = row[`value.${locale}`];
298
+ if (key || value) {
299
+ item.i18n[locale] = {
300
+ key: key || '',
301
+ value: value || ''
302
+ };
303
+ }
304
+ });
305
+ group.items.push(item);
306
+ });
307
+ // Sort groups and items by position
308
+ const groups = Array.from(groupsMap.values()).sort((a, b) => a.position - b.position);
309
+ groups.forEach(group => {
310
+ group.items.sort((a, b) => a.position - b.position);
311
+ });
312
+ return {
313
+ default_locale: defaultLocale,
314
+ groups
315
+ };
316
+ }
317
+ /**
318
+ * Merge imported specs with existing specs
319
+ */
320
+ function mergeSpecs(existing, imported, strategy = 'merge') {
321
+ if (strategy === 'replace') {
322
+ return imported;
323
+ }
324
+ if (strategy === 'append') {
325
+ const maxPosition = Math.max(...existing.groups.map(g => g.position), 0);
326
+ imported.groups.forEach((group, index) => {
327
+ group.position = maxPosition + index + 1;
328
+ });
329
+ return {
330
+ default_locale: existing.default_locale,
331
+ groups: [...existing.groups, ...imported.groups]
332
+ };
333
+ }
334
+ // Merge strategy
335
+ const mergedGroups = new Map();
336
+ // Add existing groups
337
+ existing.groups.forEach(group => {
338
+ mergedGroups.set(group.key, { ...group });
339
+ });
340
+ // Merge imported groups
341
+ imported.groups.forEach(importedGroup => {
342
+ const existingGroup = mergedGroups.get(importedGroup.key);
343
+ if (existingGroup) {
344
+ // Merge i18n
345
+ existingGroup.i18n = {
346
+ ...existingGroup.i18n,
347
+ ...importedGroup.i18n
348
+ };
349
+ // Merge items
350
+ const itemsMap = new Map();
351
+ // Add existing items
352
+ existingGroup.items.forEach(item => {
353
+ const key = Object.values(item.i18n)[0]?.key || `item-${item.position}`;
354
+ itemsMap.set(key, item);
355
+ });
356
+ // Merge imported items
357
+ importedGroup.items.forEach(importedItem => {
358
+ const key = Object.values(importedItem.i18n)[0]?.key || `item-${importedItem.position}`;
359
+ const existingItem = itemsMap.get(key);
360
+ if (existingItem) {
361
+ // Merge i18n
362
+ existingItem.i18n = {
363
+ ...existingItem.i18n,
364
+ ...importedItem.i18n
365
+ };
366
+ existingItem.visible = importedItem.visible;
367
+ }
368
+ else {
369
+ itemsMap.set(key, importedItem);
370
+ }
371
+ });
372
+ existingGroup.items = Array.from(itemsMap.values())
373
+ .sort((a, b) => a.position - b.position);
374
+ }
375
+ else {
376
+ mergedGroups.set(importedGroup.key, importedGroup);
377
+ }
378
+ });
379
+ return {
380
+ default_locale: existing.default_locale,
381
+ groups: Array.from(mergedGroups.values())
382
+ .sort((a, b) => a.position - b.position)
383
+ };
384
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Locale utility functions
3
+ */
4
+ import { LocaleUtils, SpecGroup, SpecItem } from '../../types';
5
+ export declare const localeUtils: LocaleUtils;
6
+ /**
7
+ * Get localized spec value with fallback
8
+ */
9
+ export declare function getLocalizedSpec(item: SpecItem, locale: string, fallbackLocale: string, field: 'key' | 'value'): string;
10
+ /**
11
+ * Get localized group label with fallback
12
+ */
13
+ export declare function getLocalizedGroupLabel(group: SpecGroup, locale: string, fallbackLocale: string): string;
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ /**
3
+ * Locale utility functions
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.localeUtils = void 0;
7
+ exports.getLocalizedSpec = getLocalizedSpec;
8
+ exports.getLocalizedGroupLabel = getLocalizedGroupLabel;
9
+ exports.localeUtils = {
10
+ getLocaleLabel: (code) => {
11
+ const labels = {
12
+ 'th-TH': 'ไทย',
13
+ 'en-US': 'English (US)',
14
+ 'en-GB': 'English (UK)',
15
+ 'zh-CN': '中文 (简体)',
16
+ 'ja-JP': '日本語',
17
+ 'ko-KR': '한국어',
18
+ 'vi-VN': 'Tiếng Việt',
19
+ 'ms-MY': 'Bahasa Melayu',
20
+ 'id-ID': 'Bahasa Indonesia',
21
+ 'es-ES': 'Español',
22
+ 'fr-FR': 'Français',
23
+ 'de-DE': 'Deutsch',
24
+ 'pt-BR': 'Português (BR)',
25
+ 'ru-RU': 'Русский',
26
+ 'ar-SA': 'العربية'
27
+ };
28
+ return labels[code] || code;
29
+ },
30
+ parseLocaleCode: (code) => {
31
+ const parts = code.split('-');
32
+ return {
33
+ language: parts[0],
34
+ region: parts[1]
35
+ };
36
+ },
37
+ getLanguageFromLocale: (code) => {
38
+ return code.split('-')[0];
39
+ },
40
+ getFallbackLocale: (locale, availableLocales, defaultLocale) => {
41
+ // Try exact match
42
+ if (availableLocales.includes(locale)) {
43
+ return locale;
44
+ }
45
+ // Try language-only match
46
+ const language = locale.split('-')[0];
47
+ const languageMatch = availableLocales.find(l => l.startsWith(language));
48
+ if (languageMatch) {
49
+ return languageMatch;
50
+ }
51
+ // Return default
52
+ return defaultLocale;
53
+ },
54
+ calculateLocaleCompleteness: (specs, locale) => {
55
+ if (!specs?.groups || specs.groups.length === 0) {
56
+ return 100; // No specs = complete
57
+ }
58
+ let total = 0;
59
+ let completed = 0;
60
+ specs.groups.forEach((group) => {
61
+ // Check group label
62
+ total++;
63
+ if (group.i18n?.[locale]?.label) {
64
+ completed++;
65
+ }
66
+ // Check items
67
+ group.items?.forEach((item) => {
68
+ total += 2; // key + value
69
+ if (item.i18n?.[locale]?.key) {
70
+ completed++;
71
+ }
72
+ if (item.i18n?.[locale]?.value) {
73
+ completed++;
74
+ }
75
+ });
76
+ });
77
+ return total === 0 ? 100 : Math.round((completed / total) * 100);
78
+ }
79
+ };
80
+ /**
81
+ * Get localized spec value with fallback
82
+ */
83
+ function getLocalizedSpec(item, locale, fallbackLocale, field) {
84
+ return item.i18n?.[locale]?.[field] ||
85
+ item.i18n?.[fallbackLocale]?.[field] ||
86
+ '';
87
+ }
88
+ /**
89
+ * Get localized group label with fallback
90
+ */
91
+ function getLocalizedGroupLabel(group, locale, fallbackLocale) {
92
+ return group.i18n?.[locale]?.label ||
93
+ group.i18n?.[fallbackLocale]?.label ||
94
+ group.key;
95
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * HTML sanitization utility
3
+ */
4
+ export interface SanitizeOptions {
5
+ allowedTags?: string[];
6
+ allowedAttributes?: Record<string, string[]>;
7
+ removeScripts?: boolean;
8
+ removeStyles?: boolean;
9
+ }
10
+ /**
11
+ * Sanitize HTML content
12
+ */
13
+ export declare function sanitizeHTML(html: string, options?: SanitizeOptions): string;
14
+ /**
15
+ * Extract plain text from HTML
16
+ */
17
+ export declare function extractPlainText(html: string, maxLength?: number): string;
18
+ /**
19
+ * Generate auto-excerpt from HTML
20
+ */
21
+ export declare function generateExcerpt(html: string, maxLength?: number): string;
22
+ /**
23
+ * Strip all HTML tags
24
+ */
25
+ export declare function stripHtml(html: string): string;
26
+ /**
27
+ * Estimate reading time
28
+ */
29
+ export declare function estimateReadingTime(html: string): number;
@@ -0,0 +1,188 @@
1
+ "use strict";
2
+ /**
3
+ * HTML sanitization utility
4
+ */
5
+ var __importDefault = (this && this.__importDefault) || function (mod) {
6
+ return (mod && mod.__esModule) ? mod : { "default": mod };
7
+ };
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.sanitizeHTML = sanitizeHTML;
10
+ exports.extractPlainText = extractPlainText;
11
+ exports.generateExcerpt = generateExcerpt;
12
+ exports.stripHtml = stripHtml;
13
+ exports.estimateReadingTime = estimateReadingTime;
14
+ const isomorphic_dompurify_1 = __importDefault(require("isomorphic-dompurify"));
15
+ const DEFAULT_ALLOWED_TAGS = [
16
+ "h1",
17
+ "h2",
18
+ "h3",
19
+ "h4",
20
+ "h5",
21
+ "h6",
22
+ "p",
23
+ "br",
24
+ "hr",
25
+ "strong",
26
+ "b",
27
+ "em",
28
+ "i",
29
+ "u",
30
+ "strike",
31
+ "s",
32
+ "del",
33
+ "ul",
34
+ "ol",
35
+ "li",
36
+ "blockquote",
37
+ "pre",
38
+ "code",
39
+ "a",
40
+ "img",
41
+ "table",
42
+ "thead",
43
+ "tbody",
44
+ "tfoot",
45
+ "tr",
46
+ "th",
47
+ "td",
48
+ "span",
49
+ "div",
50
+ ];
51
+ const DEFAULT_ALLOWED_ATTRIBUTES = {
52
+ a: ["href", "target", "rel", "title"],
53
+ img: ["src", "alt", "title", "width", "height"],
54
+ blockquote: ["cite"],
55
+ code: ["class"],
56
+ pre: ["class"],
57
+ span: ["class", "style"],
58
+ div: ["class"],
59
+ table: ["class"],
60
+ th: ["colspan", "rowspan", "align"],
61
+ td: ["colspan", "rowspan", "align"],
62
+ };
63
+ /**
64
+ * Sanitize HTML content
65
+ */
66
+ function sanitizeHTML(html, options = {}) {
67
+ const { allowedTags = DEFAULT_ALLOWED_TAGS, allowedAttributes = DEFAULT_ALLOWED_ATTRIBUTES, removeScripts = true, removeStyles = true, } = options;
68
+ // Configure DOMPurify
69
+ const config = {
70
+ ALLOWED_TAGS: allowedTags,
71
+ ALLOWED_ATTR: Object.keys(allowedAttributes).reduce((acc, tag) => {
72
+ allowedAttributes[tag].forEach((attr) => {
73
+ acc.push(attr);
74
+ });
75
+ return acc;
76
+ }, []),
77
+ KEEP_CONTENT: true,
78
+ ALLOW_DATA_ATTR: false,
79
+ FORBID_TAGS: removeScripts ? ["script", "style"] : [],
80
+ FORBID_ATTR: removeStyles ? ["style"] : [],
81
+ };
82
+ // Add custom hook to handle specific attributes per tag
83
+ isomorphic_dompurify_1.default.addHook("afterSanitizeAttributes", (node) => {
84
+ const tagName = node.tagName?.toLowerCase();
85
+ if (tagName && allowedAttributes[tagName]) {
86
+ const allowedAttrs = allowedAttributes[tagName];
87
+ const attrs = node.attributes;
88
+ if (attrs) {
89
+ for (let i = attrs.length - 1; i >= 0; i--) {
90
+ const attr = attrs[i];
91
+ if (!allowedAttrs.includes(attr.name)) {
92
+ node.removeAttribute(attr.name);
93
+ }
94
+ }
95
+ }
96
+ }
97
+ // Ensure links are safe
98
+ if (tagName === "a") {
99
+ const href = node.getAttribute("href");
100
+ if (href && !isValidUrl(href)) {
101
+ node.removeAttribute("href");
102
+ }
103
+ // Add rel="noopener noreferrer" for external links
104
+ const target = node.getAttribute("target");
105
+ if (target === "_blank") {
106
+ node.setAttribute("rel", "noopener noreferrer");
107
+ }
108
+ }
109
+ // Validate image sources
110
+ if (tagName === "img") {
111
+ const src = node.getAttribute("src");
112
+ if (src && !isValidImageUrl(src)) {
113
+ node.removeAttribute("src");
114
+ }
115
+ }
116
+ });
117
+ const cleaned = isomorphic_dompurify_1.default.sanitize(html, config);
118
+ // Remove hook after use
119
+ isomorphic_dompurify_1.default.removeHook("afterSanitizeAttributes");
120
+ return String(cleaned);
121
+ }
122
+ /**
123
+ * Extract plain text from HTML
124
+ */
125
+ function extractPlainText(html, maxLength) {
126
+ const tempDiv = document.createElement("div");
127
+ tempDiv.innerHTML = sanitizeHTML(html);
128
+ let text = tempDiv.textContent || tempDiv.innerText || "";
129
+ text = text.replace(/\s+/g, " ").trim();
130
+ if (maxLength && text.length > maxLength) {
131
+ text = text.substring(0, maxLength) + "...";
132
+ }
133
+ return text;
134
+ }
135
+ /**
136
+ * Generate auto-excerpt from HTML
137
+ */
138
+ function generateExcerpt(html, maxLength = 160) {
139
+ const plainText = extractPlainText(html, maxLength);
140
+ return plainText;
141
+ }
142
+ /**
143
+ * Validate URL
144
+ */
145
+ function isValidUrl(url) {
146
+ try {
147
+ // Allow relative URLs
148
+ if (url.startsWith("/") || url.startsWith("#")) {
149
+ return true;
150
+ }
151
+ const urlObj = new URL(url);
152
+ // Only allow http(s) and mailto
153
+ return ["http:", "https:", "mailto:"].includes(urlObj.protocol);
154
+ }
155
+ catch {
156
+ return false;
157
+ }
158
+ }
159
+ /**
160
+ * Validate image URL
161
+ */
162
+ function isValidImageUrl(url) {
163
+ // Allow data URLs for embedded images
164
+ if (url.startsWith("data:image/")) {
165
+ return true;
166
+ }
167
+ // Allow relative URLs
168
+ if (url.startsWith("/")) {
169
+ return true;
170
+ }
171
+ // Check if it's a valid absolute URL
172
+ return isValidUrl(url);
173
+ }
174
+ /**
175
+ * Strip all HTML tags
176
+ */
177
+ function stripHtml(html) {
178
+ return html.replace(/<[^>]*>/g, "");
179
+ }
180
+ /**
181
+ * Estimate reading time
182
+ */
183
+ function estimateReadingTime(html) {
184
+ const text = extractPlainText(html);
185
+ const wordsPerMinute = 200;
186
+ const words = text.split(/\s+/).length;
187
+ return Math.ceil(words / wordsPerMinute);
188
+ }