From fdb5e3c42085f6d6cca4a7a481cac319dffbf707 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 8 Aug 2023 14:37:39 +0530 Subject: [PATCH] feat: add support for serializing html attributes --- package.json | 3 +- src/component/props.ts | 2 +- src/template.ts | 2 +- src/utils.ts | 143 ++++++++++++++++++++++ tests/template.spec.ts | 270 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 417 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 951352c..4166ae0 100644 --- a/package.json +++ b/package.json @@ -58,12 +58,13 @@ "@poppinss/inspect": "^1.0.1", "@poppinss/macroable": "^1.0.0-7", "@poppinss/utils": "^6.5.0-5", + "classnames": "^2.3.2", "edge-error": "^4.0.0-0", "edge-lexer": "^6.0.0-4", "edge-parser": "^9.0.0-2", "he": "^1.2.0", "js-stringify": "^1.0.2", - "stringify-attributes": "^4.0.0" + "property-information": "^6.2.0" }, "author": "virk", "license": "MIT", diff --git a/src/component/props.ts b/src/component/props.ts index 4d37103..9ad406e 100644 --- a/src/component/props.ts +++ b/src/component/props.ts @@ -8,10 +8,10 @@ */ import lodash from '@poppinss/utils/lodash' -import stringifyAttributes from 'stringify-attributes' import { htmlSafe } from '../template.js' import { PropsContract } from '../types.js' +import { stringifyAttributes } from '../utils.js' /** * Class to ease interactions with component props diff --git a/src/template.ts b/src/template.ts index 0cedfff..9dc934c 100644 --- a/src/template.ts +++ b/src/template.ts @@ -11,11 +11,11 @@ import he from 'he' import { EdgeError } from 'edge-error' import lodash from '@poppinss/utils/lodash' import Macroable from '@poppinss/macroable' -import stringifyAttributes from 'stringify-attributes' import { Compiler } from './compiler.js' import { Processor } from './processor.js' import { Props } from './component/props.js' +import { stringifyAttributes } from './utils.js' import type { CompiledTemplate } from './types.js' /** diff --git a/src/utils.ts b/src/utils.ts index 2eb25da..20c707f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,12 +7,81 @@ * file that was distributed with this source code. */ +// @ts-expect-error untyped module +import JSStringify from 'js-stringify' +import classNames from 'classnames' import { EdgeError } from 'edge-error' import type { TagToken } from 'edge-lexer/types' +import { find, html } from 'property-information' import { expressions as expressionsList, Parser } from 'edge-parser' type ExpressionList = readonly (keyof typeof expressionsList)[] +/** + * Function to register custom properties + * with "property-information" package. + */ +function definePropertyInformation(property: string, value?: any) { + html.normal[property] = property + html.property[property] = { + attribute: property, + boolean: true, + property: property, + space: 'html', + booleanish: false, + commaOrSpaceSeparated: false, + commaSeparated: false, + spaceSeparated: false, + number: false, + overloadedBoolean: false, + defined: false, + mustUseProperty: false, + ...value, + } +} + +definePropertyInformation('x-cloak') +definePropertyInformation('x-ignore') +definePropertyInformation('x-transition:enterstart', { + attribute: 'x-transition:enter-start', + property: 'x-transition:enterStart', + boolean: false, + spaceSeparated: true, + commaOrSpaceSeparated: true, +}) +definePropertyInformation('x-transition:enterend', { + attribute: 'x-transition:enter-end', + property: 'x-transition:enterEnd', + boolean: false, + spaceSeparated: true, + commaOrSpaceSeparated: true, +}) +definePropertyInformation('x-transition:leavestart', { + attribute: 'x-transition:leave-start', + property: 'x-transition:leaveStart', + boolean: false, + spaceSeparated: true, + commaOrSpaceSeparated: true, +}) +definePropertyInformation('x-transition:leaveend', { + attribute: 'x-transition:leave-end', + property: 'x-transition:leaveEnd', + boolean: false, + spaceSeparated: true, + commaOrSpaceSeparated: true, +}) + +/** + * Alpine namespaces we handle with special + * rules when stringifying attributes + */ +const alpineNamespaces: Record = { + x: 'x-', + xOn: 'x-on:', + xBind: 'x-bind:', + xTransition: 'x-transition:', +} + /** * Raise an `E_UNALLOWED_EXPRESSION` exception. Filename and expression is * required to point the error stack to the correct file @@ -220,3 +289,77 @@ export class StringifiedObject { return objectifyString.flush() } } + +/** + * Stringify an object to props to HTML attributes + */ +export function stringifyAttributes(props: any, namespace?: string): string { + const attributes = Object.keys(props) + if (attributes.length === 0) { + return '' + } + + return `${attributes + .reduce((result, key) => { + let value = props[key] + key = namespace ? `${namespace}${key}` : key + + /** + * No value defined, remove attribute + */ + if (!value) { + return result + } + + /** + * Handle alpine properties separately + */ + if (alpineNamespaces[key] && typeof value === 'object') { + result = result.concat(stringifyAttributes(value, alpineNamespaces[key])) + return result + } + + const propInfo = find(html, key) + const attribute = propInfo.attribute + + /** + * Ignore svg elements and their attributes + */ + if (!propInfo || propInfo.space === 'svg') { + return result + } + + /** + * Boolean properties + */ + if (propInfo.boolean) { + result.push(attribute) + return result + } + + /** + * Encoding rules for certain properties. + * + * - Class values can be objects with conditionals + * - x-data as an object will be converted to a JSON value + * - Arrays will be concatenated into a string list and html escaped + * - Non-booleanish and numeric properties will be html escaped + */ + if (key === 'class') { + value = `"${classNames(value)}"` + } else if (key === 'x-data') { + value = typeof value === 'string' ? `"${value}"` : JSStringify(value) + } else if (Array.isArray(value)) { + value = `"${value.join(propInfo.commaSeparated ? ',' : ' ')}"` + } else if (!propInfo.booleanish && !propInfo.number) { + value = `"${String(value)}"` + } + + /** + * Push attribute value string + */ + result.push(`${attribute}=${value}`) + return result + }, []) + .join(' ')}` +} diff --git a/tests/template.spec.ts b/tests/template.spec.ts index 98b7610..eb6f858 100644 --- a/tests/template.spec.ts +++ b/tests/template.spec.ts @@ -20,6 +20,7 @@ import { Processor } from '../src/processor.js' import { includeTag } from '../src/tags/include.js' import { componentTag } from '../src/tags/component.js' import { Template, htmlSafe } from '../src/template.js' +import dedent from 'dedent-js' const tags = { slot: slotTag, component: componentTag, include: includeTag } const fs = new Filesystem(join(path.dirname(fileURLToPath(import.meta.url)), 'views')) @@ -249,3 +250,272 @@ test.group('Template', (group) => { assert.equal(partailWithInlineVariables(template, {}, {}, 'virk').trim(), 'Hello virk') }) }) + +test.group('Template | toAttributes', () => { + test('serialize object to HTML attributes', async ({ assert }) => { + const processor = new Processor() + const compiler = new Compiler(loader, tags, processor, { cache: false }) + const template = new Template(compiler, {}, {}, processor) + + const html = await template.renderRaw( + dedent` + + `, + { + hasError: false, + } + ) + + assert.equal( + html, + dedent` + + ` + ) + }) + + test('allow properties with no values', async ({ assert }) => { + const processor = new Processor() + const compiler = new Compiler(loader, tags, processor, { cache: false }) + const template = new Template(compiler, {}, {}, processor) + + const html = await template.renderRaw( + dedent` + + `, + {} + ) + + assert.equal( + html, + dedent` + + ` + ) + }) + + test('allow non-standard attributes', async ({ assert }) => { + const processor = new Processor() + const compiler = new Compiler(loader, tags, processor, { cache: false }) + const template = new Template(compiler, {}, {}, processor) + + const html = await template.renderRaw( + dedent` + + `, + { + hasError: false, + } + ) + + assert.equal( + html, + dedent` + + ` + ) + }) + + test('define comma separated values', async ({ assert }) => { + const processor = new Processor() + const compiler = new Compiler(loader, tags, processor, { cache: false }) + const template = new Template(compiler, {}, {}, processor) + + const html = await template.renderRaw( + dedent` + + `, + {} + ) + + assert.equal( + html, + dedent` + + ` + ) + }) + + test('define alpine js attributes', async ({ assert }) => { + const processor = new Processor() + const compiler = new Compiler(loader, tags, processor, { cache: false }) + const template = new Template(compiler, {}, {}, processor) + + const html = await template.renderRaw( + dedent` +
+
+ `, + {} + ) + + assert.equal( + html, + dedent` +
+
+ ` + ) + }) + + test('define alpine js boolean attributes', async ({ assert }) => { + const processor = new Processor() + const compiler = new Compiler(loader, tags, processor, { cache: false }) + const template = new Template(compiler, {}, {}, processor) + + const html = await template.renderRaw( + dedent` +
+
+ `, + {} + ) + + assert.equal( + html, + dedent` +
+
+ ` + ) + }) + + test('define alpine js event listeners', async ({ assert }) => { + const processor = new Processor() + const compiler = new Compiler(loader, tags, processor, { cache: false }) + const template = new Template(compiler, {}, {}, processor) + + const html = await template.renderRaw( + dedent` +
+
+ `, + {} + ) + + assert.equal( + html, + dedent` +
+
+ ` + ) + }) + + test('define alpinejs x-bind properties', async ({ assert }) => { + const processor = new Processor() + const compiler = new Compiler(loader, tags, processor, { cache: false }) + const template = new Template(compiler, {}, {}, processor) + + const html = await template.renderRaw( + dedent` +
+
+ `, + {} + ) + + assert.equal( + html, + dedent` +
+
+ ` + ) + }) + + test('define alpinejs transition properties', async ({ assert }) => { + const processor = new Processor() + const compiler = new Compiler(loader, tags, processor, { cache: false }) + const template = new Template(compiler, {}, {}, processor) + + const html = await template.renderRaw( + dedent` +
+
+ `, + {} + ) + + assert.equal( + html, + dedent` +
+
+ ` + ) + }) +})