@burger-editor/frozen-patty 4.0.0-alpha.2 → 4.0.0-alpha.4

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.
@@ -1,5 +1,6 @@
1
- import get from './get.js';
2
- import set from './set.js';
1
+ import { getComponent } from './get-component.js';
2
+ import { setComponent } from './set-component.js';
3
+ import { sanitizeHtml } from './utils.js';
3
4
  export default class FrozenPatty {
4
5
  #attr = 'field';
5
6
  #dom;
@@ -8,6 +9,10 @@ export default class FrozenPatty {
8
9
  */
9
10
  #filter;
10
11
  #typeConvert = false;
12
+ /**
13
+ * Enable XSS protection
14
+ */
15
+ #xssSanitize = true;
11
16
  /**
12
17
  *
13
18
  * @param html Original HTML
@@ -15,7 +20,14 @@ export default class FrozenPatty {
15
20
  */
16
21
  constructor(html, options) {
17
22
  this.#dom = document.createElement('fp-placeholer');
18
- this.#dom.innerHTML = html;
23
+ // Sanitize initial HTML if XSS protection is enabled
24
+ if (options?.xssSanitize === false) {
25
+ this.#dom.innerHTML = html;
26
+ this.#xssSanitize = false;
27
+ }
28
+ else {
29
+ this.#dom.innerHTML = sanitizeHtml(html);
30
+ }
19
31
  if (options) {
20
32
  if (options.attr) {
21
33
  this.#attr = options.attr;
@@ -27,7 +39,8 @@ export default class FrozenPatty {
27
39
  merge(data) {
28
40
  const currentData = this.toJSON(false);
29
41
  const newData = Object.assign(currentData, data);
30
- this.#dom = set(this.#dom, newData, this.#attr, this.#filter);
42
+ // Pass XSS protection flag to set (default is true if not set)
43
+ this.#dom = setComponent(this.#dom, newData, this.#attr, this.#filter, this.#xssSanitize);
31
44
  return this;
32
45
  }
33
46
  toDOM() {
@@ -38,6 +51,6 @@ export default class FrozenPatty {
38
51
  }
39
52
  toJSON(filtering = true) {
40
53
  const filter = filtering ? this.#filter : undefined;
41
- return get(this.#dom, this.#attr, this.#typeConvert, filter);
54
+ return getComponent(this.#dom, this.#attr, this.#typeConvert, filter);
42
55
  }
43
56
  }
@@ -0,0 +1,9 @@
1
+ import type { Filter, FrozenPattyData } from './types.js';
2
+ /**
3
+ *
4
+ * @param el
5
+ * @param attr
6
+ * @param typeConvert
7
+ * @param filter
8
+ */
9
+ export declare function getComponent(el: Element, attr: string, typeConvert: boolean, filter?: Filter): FrozenPattyData;
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import { fieldNameParser } from './field-name-parser.js';
1
+ import { parseFields } from './parse-fields.js';
2
2
  import { kebabCase } from './utils.js';
3
3
  /**
4
4
  * Get value from an 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 rawValue = el.getAttribute(`data-${attr}`);
13
- const listRoot = el.closest(`[data-${attr}-list]`);
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 fieldList = rawValue.split(/\s*,\s*/);
19
- for (const field of fieldList) {
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 } = fieldNameParser(field);
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, attr, propName, convertType);
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, attr, keyAttr, typeConvert) {
90
+ function getAttribute(el, keyAttr, attr, typeConvert) {
85
91
  switch (keyAttr) {
86
92
  case 'contenteditable': {
87
93
  if (el instanceof HTMLElement) {
@@ -103,24 +109,35 @@ function getAttribute(el, attr, keyAttr, typeConvert) {
103
109
  return el.hasAttribute('download') ? el.getAttribute('download') : null;
104
110
  }
105
111
  case 'href': {
106
- // return (el as HTMLAnchorElement).href;
107
- return el.getAttribute(keyAttr) ?? ''; // return plain string
112
+ // Example: (el as HTMLAnchorElement).href;
113
+ // Expected: return defined value of plain string
114
+ return el.getAttribute(keyAttr) ?? '';
108
115
  }
109
- default: {
110
- let value;
111
- const dataAttr = ['data', attr, kebabCase(keyAttr)].join('-');
112
- if (el.hasAttribute(dataAttr)) {
113
- value = el.getAttribute(dataAttr) || '';
114
- }
115
- else {
116
- value = el.getAttribute(keyAttr) || '';
117
- }
118
- if (typeConvert) {
119
- value = convert(value);
120
- }
121
- return value;
116
+ }
117
+ let value =
118
+ // @ts-ignore
119
+ el[keyAttr];
120
+ if (value !== undefined) {
121
+ if (typeConvert) {
122
+ value = convert(value);
122
123
  }
124
+ return value;
123
125
  }
126
+ // For shorthand notation, get value from data-field-* attribute
127
+ const dataAttr = ['data', attr, kebabCase(keyAttr)].join('-');
128
+ if (el.hasAttribute(dataAttr)) {
129
+ return el.getAttribute(dataAttr) ?? '';
130
+ }
131
+ value = el.getAttribute(keyAttr) ?? null;
132
+ if ((value === '' || value == null) &&
133
+ typeof keyAttr === 'string' &&
134
+ keyAttr.startsWith('data-')) {
135
+ return '';
136
+ }
137
+ if (typeConvert) {
138
+ value = convert(value);
139
+ }
140
+ return value;
124
141
  }
125
142
  /**
126
143
  *
@@ -128,8 +145,9 @@ function getAttribute(el, attr, keyAttr, typeConvert) {
128
145
  */
129
146
  function convert(value) {
130
147
  value = parse(value);
131
- if (URL.canParse(value)) {
132
- const url = new URL(value, location.href);
148
+ const str = `${value}`;
149
+ if (URL.canParse(str)) {
150
+ const url = new URL(str, location.href);
133
151
  if (url.origin === location.origin) {
134
152
  return url.pathname;
135
153
  }
@@ -142,7 +160,7 @@ function convert(value) {
142
160
  */
143
161
  function parse(value) {
144
162
  try {
145
- return JSON.parse(value);
163
+ return JSON.parse(`${value}`);
146
164
  }
147
165
  catch {
148
166
  return value;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * picture要素の特別な処理を行います
3
+ * @param pictureElement picture要素
4
+ * @param attr
5
+ */
6
+ export declare function handlePictureElement(pictureElement: HTMLPictureElement, attr: string): void;
@@ -0,0 +1,31 @@
1
+ import { parseFields } from './parse-fields.js';
2
+ import { setValue } from './set-value.js';
3
+ /**
4
+ * picture要素の特別な処理を行います
5
+ * @param pictureElement picture要素
6
+ * @param attr
7
+ */
8
+ export function handlePictureElement(pictureElement, attr) {
9
+ const children = [...pictureElement.children];
10
+ const fieldAttrName = `data-${attr}`;
11
+ for (const [i, child] of children.entries()) {
12
+ const fields = parseFields(child.getAttribute(fieldAttrName) ?? '');
13
+ const newChild = child.cloneNode(false);
14
+ child.remove();
15
+ if (i > 0) {
16
+ setValue(newChild, 'node', 'source', attr);
17
+ }
18
+ switch (newChild.localName) {
19
+ case 'source': {
20
+ // newChild.removeAttribute('alt');
21
+ // setValue(newChild, name, attr.value);
22
+ break;
23
+ }
24
+ case 'img': {
25
+ //
26
+ break;
27
+ }
28
+ }
29
+ pictureElement.insertBefore(newChild, pictureElement.firstChild);
30
+ }
31
+ }
@@ -0,0 +1,6 @@
1
+ import type { FieldDefinition } from './types.js';
2
+ /**
3
+ *
4
+ * @param query
5
+ */
6
+ export declare function parseFields(query: string): readonly FieldDefinition[];
@@ -0,0 +1,38 @@
1
+ import { camelCase } from './utils.js';
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,71 @@
1
+ import { setValue } from './set-value.js';
2
+ import { stringifyFields } from './stringify-fields.js';
3
+ import { definedFields, flattenData, getFields, hasField, kebabCase, maxLengthOf, removeProp, replaceNode, replaceProp, } from './utils.js';
4
+ /**
5
+ *
6
+ * @param el
7
+ * @param data
8
+ * @param attr
9
+ * @param filter
10
+ * @param xssSanitize Enable XSS protection
11
+ */
12
+ export function setComponent(el, data, attr, filter, xssSanitize = true) {
13
+ el = el.cloneNode(true);
14
+ for (const dataKeyName in data) {
15
+ const datum = data[dataKeyName];
16
+ if (Array.isArray(datum)) {
17
+ continue;
18
+ }
19
+ const targetList = [
20
+ // 自身を含める
21
+ el,
22
+ // パフォーマンスのために雑なフィルタリングを行う
23
+ ...el.querySelectorAll(`[data-${attr}*="${kebabCase(dataKeyName)}"]`),
24
+ ]
25
+ // より正確なフィルタリングを行う
26
+ .filter((el) => hasField(el, attr, dataKeyName));
27
+ for (const targetEl of targetList) {
28
+ setValue(targetEl, dataKeyName, datum, attr, filter, xssSanitize);
29
+ }
30
+ }
31
+ for (const listRoot of el.querySelectorAll(`[data-${attr}-list]`)) {
32
+ const decendants = listRoot.querySelectorAll('*');
33
+ const definedFieldsOfDecendants = new Set([...decendants].flatMap((el) => definedFields(el, attr)));
34
+ const maxLength = maxLengthOf(data, [...definedFieldsOfDecendants]);
35
+ const listItem = listRoot.children[0]?.cloneNode(true);
36
+ if (!listItem) {
37
+ continue;
38
+ }
39
+ while (listRoot.firstChild) {
40
+ listRoot.firstChild.remove();
41
+ }
42
+ for (let i = 0; i < maxLength; i++) {
43
+ let item = listItem.cloneNode(true);
44
+ const itemData = flattenData(data, i);
45
+ let reverse = false;
46
+ switch (listRoot.localName) {
47
+ case 'picture': {
48
+ reverse = true;
49
+ if (i > 0) {
50
+ // 2つ目以降はsource要素に変換
51
+ let fields = getFields(item, attr);
52
+ item = replaceNode(item, 'source', attr, xssSanitize);
53
+ fields = replaceProp(fields, 'src', 'srcset');
54
+ fields = removeProp(fields, 'alt');
55
+ const fieldQuery = stringifyFields(fields);
56
+ item.setAttribute(`data-${attr}`, fieldQuery);
57
+ }
58
+ break;
59
+ }
60
+ }
61
+ const newEl = setComponent(item, itemData, attr, filter, xssSanitize);
62
+ if (reverse) {
63
+ listRoot.prepend(newEl);
64
+ }
65
+ else {
66
+ listRoot.append(newEl);
67
+ }
68
+ }
69
+ }
70
+ return el;
71
+ }
@@ -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,80 @@
1
+ import { setValue } from './set-value.js';
2
+ import { stringifyFields } from './stringify-fields.js';
3
+ import { definedFields, flattenData, getFields, hasField, kebabCase, maxLengthOf, removeProp, replaceNode, replaceProp, } from './utils.js';
4
+ /**
5
+ *
6
+ * @param el
7
+ * @param data
8
+ * @param attr
9
+ * @param filter
10
+ * @param xssSanitize Enable XSS protection
11
+ */
12
+ export function setComponent(el, data, attr, filter, xssSanitize = true) {
13
+ el = el.cloneNode(true);
14
+ for (const dataKeyName in data) {
15
+ const datum = data[dataKeyName];
16
+ if (Array.isArray(datum)) {
17
+ continue;
18
+ }
19
+ const targetList = [
20
+ // Include self
21
+ el,
22
+ // Perform rough filtering for performance
23
+ ...el.querySelectorAll(`[data-${attr}*="${kebabCase(dataKeyName)}"]`),
24
+ ]
25
+ // Perform more accurate filtering
26
+ .filter((el) => hasField(el, attr, dataKeyName));
27
+ for (const targetEl of targetList) {
28
+ setValue(targetEl, dataKeyName, datum, attr, filter, xssSanitize);
29
+ }
30
+ }
31
+ for (const listRoot of el.querySelectorAll(`[data-${attr}-list]`)) {
32
+ const decendants = listRoot.querySelectorAll('*');
33
+ const definedFieldsOfDecendants = new Set([...decendants].flatMap((el) => definedFields(el, attr)));
34
+ const maxLength = maxLengthOf(data, [...definedFieldsOfDecendants]);
35
+ const listItem = listRoot.children[0]?.cloneNode(true);
36
+ if (!listItem) {
37
+ continue;
38
+ }
39
+ while (listRoot.firstChild) {
40
+ listRoot.firstChild.remove();
41
+ }
42
+ for (let i = 0; i < maxLength; i++) {
43
+ let item = listItem.cloneNode(true);
44
+ const itemData = flattenData(data, i);
45
+ let reverse = false;
46
+ switch (listRoot.localName) {
47
+ case 'picture': {
48
+ reverse = true;
49
+ let fields = getFields(item, attr);
50
+ if (i === 0) {
51
+ // Convert first item to img element
52
+ item = replaceNode(item, 'img', attr, xssSanitize);
53
+ fields = replaceProp(fields, 'srcset', 'src');
54
+ fields = removeProp(fields, 'sizes');
55
+ const fieldQuery = stringifyFields(fields);
56
+ item.setAttribute(`data-${attr}`, fieldQuery);
57
+ }
58
+ else {
59
+ // Convert subsequent items to source elements
60
+ item = replaceNode(item, 'source', attr, xssSanitize);
61
+ fields = replaceProp(fields, 'src', 'srcset');
62
+ fields = removeProp(fields, 'alt');
63
+ fields = removeProp(fields, 'loading');
64
+ const fieldQuery = stringifyFields(fields);
65
+ item.setAttribute(`data-${attr}`, fieldQuery);
66
+ }
67
+ break;
68
+ }
69
+ }
70
+ const newEl = setComponent(item, itemData, attr, filter, xssSanitize);
71
+ if (reverse) {
72
+ listRoot.prepend(newEl);
73
+ }
74
+ else {
75
+ listRoot.append(newEl);
76
+ }
77
+ }
78
+ }
79
+ return el;
80
+ }
@@ -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;