@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.
- package/README.md +202 -0
- package/dist/admin/hooks/useProductContent.d.ts +25 -0
- package/dist/admin/hooks/useProductContent.js +165 -0
- package/dist/admin/lib/sdk.d.ts +2 -0
- package/dist/admin/lib/sdk.js +15 -0
- package/dist/admin/routes/product-content/page.d.ts +8 -0
- package/dist/admin/routes/product-content/page.js +158 -0
- package/dist/admin/utils/import-export.d.ts +30 -0
- package/dist/admin/utils/import-export.js +384 -0
- package/dist/admin/utils/locale.d.ts +13 -0
- package/dist/admin/utils/locale.js +95 -0
- package/dist/admin/utils/sanitize.d.ts +29 -0
- package/dist/admin/utils/sanitize.js +188 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -0
- package/dist/modules/product-content/index.d.ts +21 -0
- package/dist/modules/product-content/index.js +12 -0
- package/dist/modules/product-content/models/product-content.d.ts +7 -0
- package/dist/modules/product-content/models/product-content.js +18 -0
- package/dist/modules/product-content/service.d.ts +12 -0
- package/dist/modules/product-content/service.js +9 -0
- package/dist/types/index.d.ts +173 -0
- package/dist/types/index.js +5 -0
- package/package.json +102 -0
|
@@ -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
|
+
}
|