diff --git a/packages/core/src/parser/config/config.ts b/packages/core/src/parser/config/config.ts index f5d9ae467..9a1977720 100644 --- a/packages/core/src/parser/config/config.ts +++ b/packages/core/src/parser/config/config.ts @@ -14,6 +14,11 @@ export type CustomParserCss = (input: string, editor: Editor) => ParsedCssRule[] export type CustomParserHtml = (input: string, options: HTMLParserOptions) => HTMLElement; +export type ConvertAttributeValuesOption = + | boolean + | readonly string[] + | ((props: { attribute: string; value: string | boolean; node: HTMLElement }) => boolean); + export interface HTMLParseResult { html: ComponentDefinitionDefined | ComponentDefinitionDefined[]; css?: CssRuleJSON[]; @@ -83,6 +88,17 @@ export interface HTMLParserOptions extends OptionAsDocument { * @default false */ convertDataGjsAttributesHyphens?: boolean; + + /** + * Convert regular HTML attribute values using the same parser used by `data-gjs-*` attributes. + * + * - `true`: converts all regular attributes. + * - `string[]`: converts only the listed attributes, matched by exact attribute name. + * - `Function`: converts attributes when the function returns `true`. + * + * @default false + */ + convertAttributeValues?: ConvertAttributeValuesOption; } export interface ParserConfig { @@ -133,6 +149,7 @@ const config: () => ParserConfig = () => ({ allowUnsafeAttrValue: false, keepEmptyTextNodes: false, convertDataGjsAttributesHyphens: false, + convertAttributeValues: false, }, }); diff --git a/packages/core/src/parser/index.ts b/packages/core/src/parser/index.ts index 5093fb410..aff795fda 100644 --- a/packages/core/src/parser/index.ts +++ b/packages/core/src/parser/index.ts @@ -63,6 +63,7 @@ export default class ParserModule extends Module|Function} [options.convertAttributeValues=false] Convert regular HTML attribute values using the same parser used by `data-gjs-*` attributes * @returns {Object} Object containing the result `{ html: ..., css: ... }` * @example * const resHtml = Parser.parseHtml(`
Hi
`, { diff --git a/packages/core/src/parser/model/ParserHtml.ts b/packages/core/src/parser/model/ParserHtml.ts index da0cf3edc..bec85aca2 100644 --- a/packages/core/src/parser/model/ParserHtml.ts +++ b/packages/core/src/parser/model/ParserHtml.ts @@ -16,8 +16,11 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo modelAttrStart, - getPropAttribute(attrName: string, attrValue?: string) { - const name = attrName.replace(this.modelAttrStart, ''); + parseAttributeValue(attrValue?: string | boolean) { + if (typeof attrValue !== 'string') { + return attrValue; + } + const valueLen = attrValue?.length || 0; const firstChar = attrValue?.substring(0, 1); const lastChar = attrValue?.substring(valueLen - 1); @@ -31,12 +34,38 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo (firstChar == '{' && lastChar == '}') || (firstChar == '[' && lastChar == ']') ? JSON.parse(value) : value; } catch (e) {} + return value; + }, + + getPropAttribute(attrName: string, attrValue?: string) { + const name = attrName.replace(this.modelAttrStart, ''); + const value = this.parseAttributeValue(attrValue); + return { name, value, }; }, + shouldConvertAttributeValue( + attribute: string, + value: string | boolean, + node: HTMLElement, + convertAttributeValues: HTMLParserOptions['convertAttributeValues'], + ) { + if (!convertAttributeValues) { + return false; + } else if (convertAttributeValues === true) { + return true; + } else if (isArray(convertAttributeValues)) { + return convertAttributeValues.includes(attribute); + } else if (isFunction(convertAttributeValues)) { + return !!convertAttributeValues({ attribute, value, node }); + } + + return false; + }, + /** * Extract component props from an attribute object * @param {Object} attr @@ -126,18 +155,23 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo return result; }, - parseNodeAttr(node: HTMLElement, modelResult?: ComponentDefinitionDefined) { + parseNodeAttr( + node: HTMLElement, + modelResult?: ComponentDefinitionDefined, + opts: HTMLParserOptions = config.optionsHtml || {}, + ) { const model = modelResult || {}; const attrs = node.attributes || []; const attrsLen = attrs.length; - const convertHyphens = !!config?.optionsHtml?.convertDataGjsAttributesHyphens; + const convertHyphens = !!opts.convertDataGjsAttributesHyphens; + const { convertAttributeValues } = opts; const defaults = (convertHyphens && !!model.type && result(em?.Components.getType(model.type)?.model.prototype, 'defaults')) || {}; for (let i = 0; i < attrsLen; i++) { let nodeName = attrs[i].nodeName; - let nodeValue: string | boolean = attrs[i].nodeValue!; + let nodeValue: any = attrs[i].nodeValue!; if (nodeName == 'style') { model.style = this.parseStyle(nodeValue); @@ -160,6 +194,10 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo nodeValue = true; } + if (this.shouldConvertAttributeValue(nodeName, nodeValue, node, convertAttributeValues)) { + nodeValue = this.parseAttributeValue(nodeValue); + } + if (!model.attributes) { model.attributes = {}; } @@ -212,7 +250,7 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo model.tagName = tag && ns === 'http://www.w3.org/1999/xhtml' ? tag.toLowerCase() : tag; } - model = this.parseNodeAttr(node, model); + model = this.parseNodeAttr(node, model, opts); // Check for custom void elements (valid in XML) if (!nodesLen && `${node.outerHTML}`.slice(-2) === '/>') { @@ -384,7 +422,7 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo if (asDocument) { res.head = this.parseNode(docEl.head, cf); - res.root = this.parseNodeAttr(root); + res.root = this.parseNodeAttr(root, undefined, cf); resHtml = this.parseNode(docEl.body, cf); } else { const result = this.parseNodes(root, cf); diff --git a/packages/core/test/specs/parser/model/ParserHtml.ts b/packages/core/test/specs/parser/model/ParserHtml.ts index f3920543b..95785fa41 100644 --- a/packages/core/test/specs/parser/model/ParserHtml.ts +++ b/packages/core/test/specs/parser/model/ParserHtml.ts @@ -823,6 +823,84 @@ describe('ParserHtml', () => { }); }); + describe('with convertAttributeValues', () => { + test('keeps regular attribute values as strings by default', () => { + const str = `
`; + const result = [ + { + tagName: 'div', + test: { key: 'value' }, + attributes: { + 'data-bool': 'true', + 'data-list': '[1,2,3]', + 'data-obj': '{"key":"value"}', + }, + }, + ]; + expect(obj.parse(str).html).toEqual(result); + }); + + test('converts all regular attribute values when true', () => { + const str = `
`; + const result = [ + { + tagName: 'div', + attributes: { + 'data-bool': true, + 'data-false': false, + 'data-list': [1, 2, 3], + 'data-obj': { key: 'value' }, + }, + }, + ]; + expect(obj.parse(str, null, { convertAttributeValues: true }).html).toEqual(result); + }); + + test('converts only exact attribute names when an array is provided', () => { + const str = ``; + const result = [ + { + tagName: 'img', + type: 'image', + attributes: { + src: ['image.png'], + srcset: '["image@2x.png"]', + 'data-test': 'false', + }, + }, + ]; + expect(obj.parse(str, null, { convertAttributeValues: ['src'] }).html).toEqual(result); + }); + + test('converts attributes with a dynamic resolver function', () => { + const str = `[1,2,3]`; + const result = [ + { + tagName: 'img', + type: 'image', + attributes: { + src: [1, 2, 3], + alt: '[1,2,3]', + }, + }, + { + tagName: 'a', + type: 'link', + attributes: { + href: '[1,2,3]', + }, + }, + ]; + + expect( + obj.parse(str, null, { + convertAttributeValues: ({ attribute, value, node }) => + attribute === 'src' && value === '[1,2,3]' && node.tagName.toLowerCase() === 'img', + }).html, + ).toEqual(result); + }); + }); + describe('with convertDataGjsAttributesHyphens OFF (default)', () => { beforeEach(() => { em = new Editor({});