@burger-editor/frozen-patty 4.0.0-alpha.2 → 4.0.0-alpha.22
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 +544 -21
- package/dist/frozen-patty.d.ts +7 -0
- package/dist/frozen-patty.js +18 -5
- package/dist/{get.d.ts → get-component.d.ts} +1 -1
- package/dist/get-component.js +29 -0
- package/dist/get-values.js +50 -31
- package/dist/parse-fields.d.ts +6 -0
- package/dist/parse-fields.js +38 -0
- package/dist/set-component.d.ts +10 -0
- package/dist/set-component.js +91 -0
- package/dist/set-value.d.ts +4 -2
- package/dist/set-value.js +110 -67
- package/dist/stringify-fields.d.ts +14 -0
- package/dist/stringify-fields.js +26 -0
- package/dist/types.d.ts +7 -0
- package/dist/utils.d.ts +72 -5
- package/dist/utils.js +246 -6
- package/package.json +7 -5
- package/LICENSE +0 -21
- package/dist/field-name-parser.d.ts +0 -8
- package/dist/field-name-parser.js +0 -18
- package/dist/get.js +0 -19
- package/dist/set.d.ts +0 -9
- package/dist/set.js +0 -60
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getValues } from './get-values.js';
|
|
2
|
+
import { arrayToHash } from './utils.js';
|
|
3
|
+
const reverseListElementSelectors = ['picture'];
|
|
4
|
+
/**
|
|
5
|
+
*
|
|
6
|
+
* @param el
|
|
7
|
+
* @param attr
|
|
8
|
+
* @param typeConvert
|
|
9
|
+
* @param filter
|
|
10
|
+
*/
|
|
11
|
+
export function getComponent(el, attr, typeConvert, filter) {
|
|
12
|
+
el = el.cloneNode(true);
|
|
13
|
+
const reverseListElements = el.querySelectorAll(`:is(${reverseListElementSelectors.join(',')})[data-${attr}-list]`);
|
|
14
|
+
for (const reverseListElement of reverseListElements) {
|
|
15
|
+
const children = [];
|
|
16
|
+
while (reverseListElement.lastElementChild) {
|
|
17
|
+
children.push(reverseListElement.lastElementChild);
|
|
18
|
+
reverseListElement.lastElementChild.remove();
|
|
19
|
+
}
|
|
20
|
+
reverseListElement.append(...children);
|
|
21
|
+
}
|
|
22
|
+
const filedElements = el.querySelectorAll(`[data-${attr}]`);
|
|
23
|
+
let values = [];
|
|
24
|
+
for (const _el of filedElements) {
|
|
25
|
+
values = [...values, ...getValues(_el, typeConvert, attr, filter)];
|
|
26
|
+
}
|
|
27
|
+
const result = arrayToHash(values);
|
|
28
|
+
return result;
|
|
29
|
+
}
|
package/dist/get-values.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { kebabCase } from '@burger-editor/utils';
|
|
2
|
+
import { parseFields } from './parse-fields.js';
|
|
3
3
|
/**
|
|
4
4
|
* Get value from an element
|
|
5
5
|
* @param el A target element
|
|
@@ -9,16 +9,16 @@ import { kebabCase } from './utils.js';
|
|
|
9
9
|
*/
|
|
10
10
|
export function getValues(el, convertType = false, attr = 'field', filter) {
|
|
11
11
|
const result = [];
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
const forceArray = !!listRoot;
|
|
15
|
-
if (rawValue == null) {
|
|
12
|
+
const query = el.getAttribute(`data-${attr}`);
|
|
13
|
+
if (query == null) {
|
|
16
14
|
throw new Error(`data-${attr} attriblute is empty.`);
|
|
17
15
|
}
|
|
18
|
-
const
|
|
19
|
-
|
|
16
|
+
const listRoot = el.closest(`[data-${attr}-list]`);
|
|
17
|
+
const forceArray = !!listRoot;
|
|
18
|
+
const fields = parseFields(query);
|
|
19
|
+
for (const field of fields) {
|
|
20
20
|
let value;
|
|
21
|
-
const { fieldName, propName } =
|
|
21
|
+
const { fieldName, propName } = field;
|
|
22
22
|
if (propName) {
|
|
23
23
|
switch (propName) {
|
|
24
24
|
case 'node': {
|
|
@@ -33,6 +33,12 @@ export function getValues(el, convertType = false, attr = 'field', filter) {
|
|
|
33
33
|
value = el.innerHTML.trim();
|
|
34
34
|
break;
|
|
35
35
|
}
|
|
36
|
+
case 'style': {
|
|
37
|
+
if (el instanceof HTMLElement) {
|
|
38
|
+
value = el.style.cssText;
|
|
39
|
+
}
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
36
42
|
default: {
|
|
37
43
|
if (/^style\([a-z-]+\)$/i.test(propName)) {
|
|
38
44
|
const css = propName.replace(/^style\(([a-z-]+)\)$/i, '$1');
|
|
@@ -49,7 +55,7 @@ export function getValues(el, convertType = false, attr = 'field', filter) {
|
|
|
49
55
|
}
|
|
50
56
|
break;
|
|
51
57
|
}
|
|
52
|
-
value = getAttribute(el,
|
|
58
|
+
value = getAttribute(el, propName, attr, convertType);
|
|
53
59
|
}
|
|
54
60
|
}
|
|
55
61
|
}
|
|
@@ -77,11 +83,11 @@ export function getValues(el, convertType = false, attr = 'field', filter) {
|
|
|
77
83
|
/**
|
|
78
84
|
*
|
|
79
85
|
* @param el
|
|
80
|
-
* @param attr
|
|
81
86
|
* @param keyAttr
|
|
87
|
+
* @param attr
|
|
82
88
|
* @param typeConvert
|
|
83
89
|
*/
|
|
84
|
-
function getAttribute(el,
|
|
90
|
+
function getAttribute(el, keyAttr, attr, typeConvert) {
|
|
85
91
|
switch (keyAttr) {
|
|
86
92
|
case 'contenteditable': {
|
|
87
93
|
if (el instanceof HTMLElement) {
|
|
@@ -102,25 +108,37 @@ function getAttribute(el, attr, keyAttr, typeConvert) {
|
|
|
102
108
|
// So to get inactive it is necessary to use the "hasAttribute" method.
|
|
103
109
|
return el.hasAttribute('download') ? el.getAttribute('download') : null;
|
|
104
110
|
}
|
|
105
|
-
case 'href':
|
|
106
|
-
|
|
107
|
-
|
|
111
|
+
case 'href':
|
|
112
|
+
case 'src': {
|
|
113
|
+
// Example: (el as HTMLAnchorElement).href;
|
|
114
|
+
// Expected: return defined value of plain string
|
|
115
|
+
return el.getAttribute(keyAttr) ?? '';
|
|
108
116
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
value = el.getAttribute(keyAttr) || '';
|
|
117
|
-
}
|
|
118
|
-
if (typeConvert) {
|
|
119
|
-
value = convert(value);
|
|
120
|
-
}
|
|
121
|
-
return value;
|
|
117
|
+
}
|
|
118
|
+
let value =
|
|
119
|
+
// @ts-ignore
|
|
120
|
+
el[keyAttr];
|
|
121
|
+
if (value !== undefined) {
|
|
122
|
+
if (typeConvert) {
|
|
123
|
+
value = convert(value);
|
|
122
124
|
}
|
|
125
|
+
return value;
|
|
123
126
|
}
|
|
127
|
+
// For shorthand notation, get value from data-field-* attribute
|
|
128
|
+
const dataAttr = ['data', attr, kebabCase(keyAttr)].join('-');
|
|
129
|
+
if (el.hasAttribute(dataAttr)) {
|
|
130
|
+
return el.getAttribute(dataAttr) ?? '';
|
|
131
|
+
}
|
|
132
|
+
value = el.getAttribute(keyAttr) ?? null;
|
|
133
|
+
if ((value === '' || value == null) &&
|
|
134
|
+
typeof keyAttr === 'string' &&
|
|
135
|
+
keyAttr.startsWith('data-')) {
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
if (typeConvert) {
|
|
139
|
+
value = convert(value);
|
|
140
|
+
}
|
|
141
|
+
return value;
|
|
124
142
|
}
|
|
125
143
|
/**
|
|
126
144
|
*
|
|
@@ -128,8 +146,9 @@ function getAttribute(el, attr, keyAttr, typeConvert) {
|
|
|
128
146
|
*/
|
|
129
147
|
function convert(value) {
|
|
130
148
|
value = parse(value);
|
|
131
|
-
|
|
132
|
-
|
|
149
|
+
const str = `${value}`;
|
|
150
|
+
if (URL.canParse(str)) {
|
|
151
|
+
const url = new URL(str, location.href);
|
|
133
152
|
if (url.origin === location.origin) {
|
|
134
153
|
return url.pathname;
|
|
135
154
|
}
|
|
@@ -142,7 +161,7 @@ function convert(value) {
|
|
|
142
161
|
*/
|
|
143
162
|
function parse(value) {
|
|
144
163
|
try {
|
|
145
|
-
return JSON.parse(value);
|
|
164
|
+
return JSON.parse(`${value}`);
|
|
146
165
|
}
|
|
147
166
|
catch {
|
|
148
167
|
return value;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { camelCase } from '@burger-editor/utils';
|
|
2
|
+
const cache = new Map();
|
|
3
|
+
/**
|
|
4
|
+
*
|
|
5
|
+
* @param query
|
|
6
|
+
*/
|
|
7
|
+
export function parseFields(query) {
|
|
8
|
+
const cached = cache.get(query);
|
|
9
|
+
if (cached) {
|
|
10
|
+
return cached;
|
|
11
|
+
}
|
|
12
|
+
const fields = query.split(',');
|
|
13
|
+
const result = fields.map(parseField);
|
|
14
|
+
cache.set(query, result);
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
*
|
|
19
|
+
* @param field
|
|
20
|
+
*/
|
|
21
|
+
function parseField(field) {
|
|
22
|
+
field = field.trim().toLowerCase();
|
|
23
|
+
if (!field) {
|
|
24
|
+
throw new Error('Field name is empty.');
|
|
25
|
+
}
|
|
26
|
+
let [fieldName, propName,
|
|
27
|
+
// eslint-disable-next-line prefer-const
|
|
28
|
+
...rest] = field.split(':');
|
|
29
|
+
if (rest.length > 0) {
|
|
30
|
+
throw new Error('Invalid field format.');
|
|
31
|
+
}
|
|
32
|
+
propName = propName?.trim() || '';
|
|
33
|
+
fieldName = fieldName?.trim() || propName || field;
|
|
34
|
+
return {
|
|
35
|
+
fieldName: camelCase(fieldName),
|
|
36
|
+
propName,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Filter, FrozenPattyData } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
*
|
|
4
|
+
* @param el
|
|
5
|
+
* @param data
|
|
6
|
+
* @param attr
|
|
7
|
+
* @param filter
|
|
8
|
+
* @param xssSanitize Enable XSS protection
|
|
9
|
+
*/
|
|
10
|
+
export declare function setComponent(el: Element, data: FrozenPattyData, attr: string, filter?: Filter, xssSanitize?: boolean): Element;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { kebabCase } from '@burger-editor/utils';
|
|
2
|
+
import { setValue } from './set-value.js';
|
|
3
|
+
import { stringifyFields } from './stringify-fields.js';
|
|
4
|
+
import { definedFields, flattenData, getFields, hasField, maxLengthOf, removeProp, replaceNode, replaceProp, } from './utils.js';
|
|
5
|
+
/**
|
|
6
|
+
*
|
|
7
|
+
* @param el
|
|
8
|
+
* @param data
|
|
9
|
+
* @param attr
|
|
10
|
+
* @param filter
|
|
11
|
+
* @param xssSanitize Enable XSS protection
|
|
12
|
+
*/
|
|
13
|
+
export function setComponent(el, data, attr, filter, xssSanitize = true) {
|
|
14
|
+
el = el.cloneNode(true);
|
|
15
|
+
for (const dataKeyName in data) {
|
|
16
|
+
const datum = data[dataKeyName];
|
|
17
|
+
if (Array.isArray(datum)) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const targetList = [
|
|
21
|
+
// Include self
|
|
22
|
+
el,
|
|
23
|
+
// Perform rough filtering for performance
|
|
24
|
+
...el.querySelectorAll(`[data-${attr}*="${kebabCase(dataKeyName)}"]`),
|
|
25
|
+
]
|
|
26
|
+
// Perform more accurate filtering
|
|
27
|
+
.filter((el) => hasField(el, attr, dataKeyName));
|
|
28
|
+
for (const targetEl of targetList) {
|
|
29
|
+
setValue(targetEl, dataKeyName, datum, attr, filter, xssSanitize);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const listRoot of el.querySelectorAll(`[data-${attr}-list]`)) {
|
|
33
|
+
const decendants = listRoot.querySelectorAll('*');
|
|
34
|
+
const definedFieldsOfDecendants = new Set([...decendants].flatMap((el) => definedFields(el, attr)));
|
|
35
|
+
const maxLength = maxLengthOf(data, [...definedFieldsOfDecendants]);
|
|
36
|
+
const listItem = listRoot.children[0]?.cloneNode(true);
|
|
37
|
+
if (!listItem) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
while (listRoot.firstChild) {
|
|
41
|
+
listRoot.firstChild.remove();
|
|
42
|
+
}
|
|
43
|
+
// Track used paths for picture elements to avoid duplicates
|
|
44
|
+
const usedPaths = new Set();
|
|
45
|
+
for (let i = 0; i < maxLength; i++) {
|
|
46
|
+
let item = listItem.cloneNode(true);
|
|
47
|
+
const itemData = flattenData(data, i);
|
|
48
|
+
let reverse = false;
|
|
49
|
+
switch (listRoot.localName) {
|
|
50
|
+
case 'picture': {
|
|
51
|
+
reverse = true;
|
|
52
|
+
// Check for duplicate paths and skip if found
|
|
53
|
+
const pathValue = itemData.path;
|
|
54
|
+
if (pathValue != null && usedPaths.has(String(pathValue))) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (pathValue != null) {
|
|
58
|
+
usedPaths.add(String(pathValue));
|
|
59
|
+
}
|
|
60
|
+
let fields = getFields(item, attr);
|
|
61
|
+
if (i === 0) {
|
|
62
|
+
// Convert first item to img element
|
|
63
|
+
item = replaceNode(item, 'img', attr, xssSanitize);
|
|
64
|
+
fields = replaceProp(fields, 'srcset', 'src');
|
|
65
|
+
fields = removeProp(fields, 'sizes');
|
|
66
|
+
const fieldQuery = stringifyFields(fields);
|
|
67
|
+
item.setAttribute(`data-${attr}`, fieldQuery);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// Convert subsequent items to source elements
|
|
71
|
+
item = replaceNode(item, 'source', attr, xssSanitize);
|
|
72
|
+
fields = replaceProp(fields, 'src', 'srcset');
|
|
73
|
+
fields = removeProp(fields, 'alt');
|
|
74
|
+
fields = removeProp(fields, 'loading');
|
|
75
|
+
const fieldQuery = stringifyFields(fields);
|
|
76
|
+
item.setAttribute(`data-${attr}`, fieldQuery);
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const newEl = setComponent(item, itemData, attr, filter, xssSanitize);
|
|
82
|
+
if (reverse) {
|
|
83
|
+
listRoot.prepend(newEl);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
listRoot.append(newEl);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return el;
|
|
91
|
+
}
|
package/dist/set-value.d.ts
CHANGED
|
@@ -10,12 +10,14 @@ import type { Filter, PrimitiveDatum } from './types.js';
|
|
|
10
10
|
* @param datum A datum of value
|
|
11
11
|
* @param attr Data attribute name for specifying the node that FrozenPatty treats as a field
|
|
12
12
|
* @param filter
|
|
13
|
+
* @param xssSanitize Enable XSS protection
|
|
13
14
|
*/
|
|
14
|
-
export declare function setValue(el: Element, name: string, datum: PrimitiveDatum, attr?: string, filter?: Filter): void;
|
|
15
|
+
export declare function setValue(el: Element, name: string, datum: PrimitiveDatum, attr?: string, filter?: Filter, xssSanitize?: boolean): void;
|
|
15
16
|
/**
|
|
16
17
|
*
|
|
17
18
|
* @param el
|
|
18
19
|
* @param datum
|
|
19
20
|
* @param asHtml
|
|
21
|
+
* @param xssSanitize
|
|
20
22
|
*/
|
|
21
|
-
export declare function setContent(el: Element, datum: PrimitiveDatum, asHtml?: boolean): void;
|
|
23
|
+
export declare function setContent(el: Element, datum: PrimitiveDatum, asHtml?: boolean, xssSanitize?: boolean): void;
|
package/dist/set-value.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { kebabCase } from '@burger-editor/utils';
|
|
2
|
+
import { parseFields } from './parse-fields.js';
|
|
3
|
+
import { propInElement, replaceNode, sanitizeAttributeValue, sanitizeHtml, } from './utils.js';
|
|
3
4
|
/**
|
|
4
5
|
* Set value to an element
|
|
5
6
|
*
|
|
@@ -11,58 +12,67 @@ import { kebabCase } from './utils.js';
|
|
|
11
12
|
* @param datum A datum of value
|
|
12
13
|
* @param attr Data attribute name for specifying the node that FrozenPatty treats as a field
|
|
13
14
|
* @param filter
|
|
15
|
+
* @param xssSanitize Enable XSS protection
|
|
14
16
|
*/
|
|
15
|
-
export function setValue(el, name, datum, attr = 'field', filter) {
|
|
17
|
+
export function setValue(el, name, datum, attr = 'field', filter, xssSanitize = true) {
|
|
16
18
|
const rawValue = el.getAttribute(`data-${attr}`);
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
default: {
|
|
49
|
-
cssValue = `${datum}`;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
el.setAttribute('style', `${cssPropertyName}: ${cssValue}`);
|
|
53
|
-
}
|
|
54
|
-
else if (el instanceof HTMLElement) {
|
|
55
|
-
// HTMLElement
|
|
56
|
-
set(el, attr, propName, datum);
|
|
19
|
+
const fields = parseFields(rawValue ?? '');
|
|
20
|
+
const field = fields.find((field) => field.fieldName === name);
|
|
21
|
+
if (!field) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const { propName } = field;
|
|
25
|
+
if (filter) {
|
|
26
|
+
datum = filter(datum);
|
|
27
|
+
}
|
|
28
|
+
// console.log({ name, field, fieldName, propName, datum, el: el.innerHTML });
|
|
29
|
+
if (!propName) {
|
|
30
|
+
setContent(el, datum, undefined, xssSanitize);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (propName === 'style' && el instanceof HTMLElement) {
|
|
34
|
+
el.style.cssText = `${datum}`;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (/^style\([a-z-]+\)$/i.test(propName)) {
|
|
38
|
+
const cssPropertyName = propName.replace(/^style\(([a-z-]+)\)$/i, '$1');
|
|
39
|
+
let cssValue;
|
|
40
|
+
switch (cssPropertyName) {
|
|
41
|
+
case 'background-image': {
|
|
42
|
+
// Using CSS method would create absolute URLs with host
|
|
43
|
+
// This would prevent migration from demo to production server
|
|
44
|
+
// Using simple string insertion (setAttribute) instead
|
|
45
|
+
// URL may not contain multibyte or whitespace characters, but escape anyway
|
|
46
|
+
const url = encodeURI(`${datum}`);
|
|
47
|
+
cssValue = `url(${url})`;
|
|
48
|
+
break;
|
|
57
49
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
50
|
+
//
|
|
51
|
+
// TODO: Handle other cases where values need units
|
|
52
|
+
//
|
|
53
|
+
default: {
|
|
54
|
+
cssValue = `${datum}`;
|
|
61
55
|
}
|
|
62
|
-
return;
|
|
63
56
|
}
|
|
64
|
-
|
|
57
|
+
el.setAttribute('style', `${cssPropertyName}: ${cssValue}`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (el instanceof HTMLElement) {
|
|
61
|
+
// HTMLElement
|
|
62
|
+
set(el, attr, propName, datum, xssSanitize);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// SVGElement or more
|
|
66
|
+
// ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
|
|
67
|
+
// Check attribute value if XSS protection is enabled
|
|
68
|
+
if (xssSanitize) {
|
|
69
|
+
const safeValue = sanitizeAttributeValue(propName, `${datum}`);
|
|
70
|
+
if (safeValue !== null) {
|
|
71
|
+
el.setAttribute(propName, safeValue);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
65
74
|
}
|
|
75
|
+
el.setAttribute(propName, `${datum}`);
|
|
66
76
|
}
|
|
67
77
|
/**
|
|
68
78
|
*
|
|
@@ -70,41 +80,48 @@ export function setValue(el, name, datum, attr = 'field', filter) {
|
|
|
70
80
|
* @param prefix
|
|
71
81
|
* @param name
|
|
72
82
|
* @param datum
|
|
83
|
+
* @param xssSanitize
|
|
73
84
|
*/
|
|
74
|
-
function set(el, prefix, name, datum) {
|
|
85
|
+
function set(el, prefix, name, datum, xssSanitize = true) {
|
|
75
86
|
if (datum == null) {
|
|
76
87
|
el.removeAttribute(name);
|
|
77
88
|
return;
|
|
78
89
|
}
|
|
79
90
|
switch (name) {
|
|
80
91
|
case 'text': {
|
|
81
|
-
setContent(el, datum, false);
|
|
92
|
+
setContent(el, datum, false, xssSanitize);
|
|
82
93
|
return;
|
|
83
94
|
}
|
|
84
95
|
case 'html': {
|
|
85
|
-
setContent(el, datum, true);
|
|
96
|
+
setContent(el, datum, true, xssSanitize);
|
|
86
97
|
return;
|
|
87
98
|
}
|
|
88
99
|
case 'node': {
|
|
89
100
|
if (typeof datum !== 'string') {
|
|
90
101
|
return;
|
|
91
102
|
}
|
|
92
|
-
|
|
93
|
-
if (nodeName === datum.toLowerCase()) {
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
const node = (el.ownerDocument ?? document).createElement(`${datum}`);
|
|
97
|
-
for (const child of el.childNodes) {
|
|
98
|
-
node.append(child.cloneNode(true));
|
|
99
|
-
}
|
|
100
|
-
for (const { name, value } of el.attributes) {
|
|
101
|
-
node.setAttribute(name, value);
|
|
102
|
-
}
|
|
103
|
-
el.replaceWith(node);
|
|
103
|
+
replaceNode(el, datum, prefix, xssSanitize);
|
|
104
104
|
return;
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
|
-
|
|
107
|
+
// If XSS protection is enabled
|
|
108
|
+
if (xssSanitize) {
|
|
109
|
+
// Don't set event handler attributes (starting with 'on')
|
|
110
|
+
if (name.toLowerCase().startsWith('on')) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const beforeSanitize = datum;
|
|
114
|
+
datum = sanitizeAttributeValue(name, datum);
|
|
115
|
+
// If sanitization results in null value (except when original value was null), don't change the value
|
|
116
|
+
if (datum === null && beforeSanitize !== null) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (datum == null) {
|
|
121
|
+
el.removeAttribute(name);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!name.startsWith('data-') && !propInElement(el, name)) {
|
|
108
125
|
const dataAttr = `data-${prefix}-${kebabCase(name)}`;
|
|
109
126
|
if (el.hasAttribute(dataAttr)) {
|
|
110
127
|
el.setAttribute(dataAttr, `${datum}`);
|
|
@@ -676,28 +693,54 @@ function set(el, prefix, name, datum) {
|
|
|
676
693
|
* @param el
|
|
677
694
|
* @param datum
|
|
678
695
|
* @param asHtml
|
|
696
|
+
* @param xssSanitize
|
|
679
697
|
*/
|
|
680
|
-
export function setContent(el, datum, asHtml = true) {
|
|
698
|
+
export function setContent(el, datum, asHtml = true, xssSanitize = true) {
|
|
681
699
|
if (el instanceof HTMLInputElement ||
|
|
682
700
|
el instanceof HTMLSelectElement ||
|
|
683
701
|
el instanceof HTMLTextAreaElement ||
|
|
684
702
|
el instanceof HTMLOutputElement) {
|
|
685
703
|
if (el instanceof HTMLInputElement &&
|
|
686
704
|
(el.type === 'radio' || el.type === 'checkbox')) {
|
|
705
|
+
// Boolean values are always set directly regardless of value attribute
|
|
687
706
|
if (typeof datum === 'boolean') {
|
|
688
707
|
el.checked = datum;
|
|
689
708
|
return;
|
|
690
709
|
}
|
|
691
|
-
|
|
710
|
+
// Checkbox/radio with value attribute: traditional value comparison
|
|
711
|
+
if (el.hasAttribute('value')) {
|
|
712
|
+
el.checked = el.value === `${datum}`;
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
// Checkbox without value attribute: switch-like behavior (string boolean processing)
|
|
716
|
+
if (datum === 'true') {
|
|
717
|
+
el.checked = true;
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (datum === 'false') {
|
|
721
|
+
el.checked = false;
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
// Other values default to false
|
|
725
|
+
el.checked = false;
|
|
692
726
|
return;
|
|
693
727
|
}
|
|
694
728
|
el.value = `${datum}`;
|
|
695
729
|
return;
|
|
696
730
|
}
|
|
697
731
|
if (asHtml) {
|
|
698
|
-
|
|
732
|
+
// Sanitize HTML if XSS protection is enabled
|
|
733
|
+
const htmlStr = datum == null ? '' : `${datum}`;
|
|
734
|
+
if (xssSanitize && typeof htmlStr === 'string') {
|
|
735
|
+
// sanitizeHtml will now replace dangerous elements instead of removing them
|
|
736
|
+
el.innerHTML = sanitizeHtml(htmlStr);
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
el.innerHTML = htmlStr;
|
|
740
|
+
}
|
|
699
741
|
return;
|
|
700
742
|
}
|
|
743
|
+
// No need to sanitize textContent (automatically escaped)
|
|
701
744
|
el.textContent = datum == null ? '' : `${datum}`;
|
|
702
745
|
}
|
|
703
746
|
/**
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FieldDefinition } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Combines multiple field definitions with comma separation
|
|
4
|
+
* @param fields Array of field definitions
|
|
5
|
+
* @returns Comma-separated field definition string
|
|
6
|
+
*/
|
|
7
|
+
export declare function stringifyFields(fields: readonly FieldDefinition[]): string;
|
|
8
|
+
/**
|
|
9
|
+
* Generates a field definition string from field name and attribute name
|
|
10
|
+
* @param fieldName Field name
|
|
11
|
+
* @param propName Attribute name (optional)
|
|
12
|
+
* @returns Field definition string
|
|
13
|
+
*/
|
|
14
|
+
export declare function stringifyField(fieldName: string, propName?: string): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Combines multiple field definitions with comma separation
|
|
3
|
+
* @param fields Array of field definitions
|
|
4
|
+
* @returns Comma-separated field definition string
|
|
5
|
+
*/
|
|
6
|
+
export function stringifyFields(fields) {
|
|
7
|
+
return fields
|
|
8
|
+
.map((field) => stringifyField(field.fieldName, field.propName))
|
|
9
|
+
.join(', ');
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Generates a field definition string from field name and attribute name
|
|
13
|
+
* @param fieldName Field name
|
|
14
|
+
* @param propName Attribute name (optional)
|
|
15
|
+
* @returns Field definition string
|
|
16
|
+
*/
|
|
17
|
+
export function stringifyField(fieldName, propName) {
|
|
18
|
+
if (!propName) {
|
|
19
|
+
return fieldName;
|
|
20
|
+
}
|
|
21
|
+
// Use shorthand notation when fieldName and propName are the same
|
|
22
|
+
if (fieldName === propName) {
|
|
23
|
+
return `:${propName}`;
|
|
24
|
+
}
|
|
25
|
+
return `${fieldName}:${propName}`;
|
|
26
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
export interface FrozenPattyData {
|
|
2
2
|
[filed: string]: PrimitiveData;
|
|
3
3
|
}
|
|
4
|
+
export interface FrozenPattyFlattenData {
|
|
5
|
+
[field: string]: PrimitiveDatum;
|
|
6
|
+
}
|
|
7
|
+
export interface FieldDefinition {
|
|
8
|
+
fieldName: string;
|
|
9
|
+
propName?: string;
|
|
10
|
+
}
|
|
4
11
|
export type PrimitiveDatum = string | number | boolean | null | undefined;
|
|
5
12
|
export type PrimitiveData = PrimitiveDatum | PrimitiveDatum[];
|
|
6
13
|
export type Filter = <T>(value: T) => T;
|