saasufy-components 1.0.5 → 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.
@@ -1,11 +1,11 @@
1
- import { getSocket } from './socket.js';
1
+ import { SocketConsumer } from './socket.js';
2
2
  import AGCollection from '/node_modules/ag-collection/ag-collection.js';
3
3
 
4
- class CollectionAdder extends HTMLElement {
4
+ class CollectionAdder extends SocketConsumer {
5
5
  constructor(options) {
6
6
  super();
7
- options = options || {};
8
- this.socket = getSocket(options.socketOptions);
7
+ this.options = options || {};
8
+
9
9
  this.typeCastFunctions = {
10
10
  text: String,
11
11
  checkbox: Boolean,
@@ -16,6 +16,7 @@ class CollectionAdder extends HTMLElement {
16
16
  }
17
17
 
18
18
  connectedCallback() {
19
+ this.socket = this.getSocket();
19
20
  this.isReady = true;
20
21
  this.render();
21
22
  }
@@ -1,14 +1,13 @@
1
- import { getSocket } from './socket.js';
1
+ import { SocketConsumer } from './socket.js';
2
2
  import AGCollection from '/node_modules/ag-collection/ag-collection.js';
3
- import { getSafeHTML } from './utils.js';
3
+ import { renderTemplate } from './utils.js';
4
4
 
5
5
  const DEFAULT_RELOAD_DELAY = 0;
6
6
 
7
- class CollectionBrowser extends HTMLElement {
7
+ class CollectionBrowser extends SocketConsumer {
8
8
  constructor() {
9
9
  super();
10
10
  this.attachShadow({ mode: 'open' });
11
- this.socket = getSocket();
12
11
 
13
12
  this.handleShowModalEvent = (event) => {
14
13
  let modal = this.shadowRoot.querySelector('slot[name="modal"]').assignedNodes()[0];
@@ -40,12 +39,13 @@ class CollectionBrowser extends HTMLElement {
40
39
  };
41
40
 
42
41
  this.handleSlotChangeEvent = () => {
43
- this.renderTable();
42
+ this.renderList();
44
43
  };
45
44
  }
46
45
 
47
46
  connectedCallback() {
48
47
  this.isReady = true;
48
+ this.socket = this.getSocket();
49
49
  this.shadowRoot.addEventListener('slotchange', this.handleSlotChangeEvent);
50
50
  this.addEventListener('showModal', this.handleShowModalEvent);
51
51
  this.addEventListener('crudCreate', this.handleCRUDCreateEvent);
@@ -146,28 +146,23 @@ class CollectionBrowser extends HTMLElement {
146
146
  }
147
147
  }
148
148
 
149
- renderTable() {
149
+ renderList() {
150
150
  if (!this.collection || !this.collection.isLoaded) return;
151
151
  let itemTemplate = this.shadowRoot.querySelector('slot[name="item"]').assignedNodes()[0];
152
152
  if (!itemTemplate) return;
153
- let listNode = this.shadowRoot.querySelector('slot[name="list"]').assignedNodes()[0];
154
- if (!listNode) return;
153
+ let viewportNode = this.shadowRoot.querySelector('slot[name="viewport"]').assignedNodes()[0];
154
+ if (!viewportNode) return;
155
155
  let items = [];
156
156
 
157
157
  for (let modelItem of this.collection.value) {
158
- let itemString = itemTemplate.innerHTML;
159
-
160
- for (let [ field, value ] of Object.entries(modelItem)) {
161
- let regExp = new RegExp(`{{${field}}}`, 'g');
162
- itemString = itemString.replace(regExp, getSafeHTML(value));
163
- }
158
+ let itemString = renderTemplate(itemTemplate.innerHTML, this.collection.type, modelItem);
164
159
  items.push(itemString);
165
160
  }
166
161
  let noItemTemplate = this.shadowRoot.querySelector('slot[name="no-item"]').assignedNodes()[0];
167
162
  if (noItemTemplate && !items.length) {
168
- listNode.innerHTML = noItemTemplate.innerHTML;
163
+ viewportNode.innerHTML = noItemTemplate.innerHTML;
169
164
  } else {
170
- listNode.innerHTML = items.join('');
165
+ viewportNode.innerHTML = items.join('');
171
166
  }
172
167
  }
173
168
 
@@ -178,6 +173,7 @@ class CollectionBrowser extends HTMLElement {
178
173
  let collectionPageSize = this.getAttribute('collection-page-size');
179
174
  let collectionViewParams = this.getAttribute('collection-view-params');
180
175
  let collectionPageOffset = this.getAttribute('collection-page-offset');
176
+ let hideErrorLogs = this.hasAttribute('hide-error-logs');
181
177
  let collectionReloadDelay = Number(
182
178
  this.getAttribute('collection-reload-delay') || DEFAULT_RELOAD_DELAY
183
179
  );
@@ -198,7 +194,7 @@ class CollectionBrowser extends HTMLElement {
198
194
  this.shadowRoot.innerHTML = `
199
195
  <slot name="item"></slot>
200
196
  <slot name="no-item"></slot>
201
- <slot name="list"></slot>
197
+ <slot name="viewport"></slot>
202
198
  <slot name="previous-page"></slot>
203
199
  <slot name="page-number"></slot>
204
200
  <slot name="next-page"></slot>
@@ -221,7 +217,7 @@ class CollectionBrowser extends HTMLElement {
221
217
  (async () => {
222
218
  await this.collection.listener('load').once();
223
219
  if (!this.collection.value.length) {
224
- this.renderTable();
220
+ this.renderList();
225
221
  }
226
222
  this.setAttribute('is-loaded', '');
227
223
  this.dispatchEvent(new CustomEvent('load', { bubbles: true }));
@@ -229,10 +225,20 @@ class CollectionBrowser extends HTMLElement {
229
225
 
230
226
  (async () => {
231
227
  for await (let event of this.collection.listener('change')) {
232
- this.renderTable();
228
+ this.renderList();
233
229
  this.updatePageButtons();
234
230
  }
235
231
  })();
232
+
233
+ if (!hideErrorLogs) {
234
+ (async () => {
235
+ for await (let { error } of this.collection.listener('error')) {
236
+ console.error(
237
+ `Collection browser encountered an error: ${error.message}`
238
+ );
239
+ }
240
+ })();
241
+ }
236
242
  }
237
243
  }
238
244
 
@@ -0,0 +1,156 @@
1
+ import { SocketConsumer } from './socket.js';
2
+ import AGCollection from '/node_modules/ag-collection/ag-collection.js';
3
+ import { renderTemplate } from './utils.js';
4
+ import './static-router.js';
5
+
6
+ const DEFAULT_RELOAD_DELAY = 0;
7
+
8
+ export class CollectionRouter extends SocketConsumer {
9
+ constructor() {
10
+ super();
11
+ this.attachShadow({ mode: 'open' });
12
+ this.pages = {};
13
+
14
+ this.handleSlotChangeEvent = () => {
15
+ let pageTemplates = this.shadowRoot.querySelector('slot[name="page"]').assignedNodes();
16
+
17
+ for (let template of pageTemplates) {
18
+ let routePath = template.getAttribute('route-path');
19
+ this.pages[routePath] = template;
20
+ }
21
+ if (this.collection && this.collection.isLoaded) {
22
+ this.renderRoutes();
23
+ }
24
+ };
25
+ }
26
+
27
+ connectedCallback() {
28
+ this.isReady = true;
29
+ this.socket = this.getSocket();
30
+ this.shadowRoot.addEventListener('slotchange', this.handleSlotChangeEvent);
31
+ this.render();
32
+ }
33
+
34
+ disconnectedCallback() {
35
+ this.shadowRoot.removeEventListener('slotchange', this.handleSlotChangeEvent);
36
+ }
37
+
38
+ static get observedAttributes() {
39
+ return [
40
+ 'collection-type',
41
+ 'collection-fields',
42
+ 'collection-view',
43
+ 'collection-view-params',
44
+ 'collection-page-size',
45
+ 'collection-page-offset',
46
+ 'hide-route-logs',
47
+ 'hide-error-logs'
48
+ ];
49
+ }
50
+
51
+ attributeChangedCallback(name, oldValue, newValue) {
52
+ if (!this.isReady) return;
53
+ if (this.collection && name === 'collection-page-offset') {
54
+ let newOffset = Number(newValue);
55
+ if (newOffset !== this.collection.meta.pageOffset) {
56
+ this.collection.fetchPage(newOffset);
57
+ }
58
+ } else {
59
+ this.render();
60
+ }
61
+ }
62
+
63
+ renderRoutes() {
64
+ let hideRouteLogs = this.hasAttribute('hide-route-logs');
65
+ let viewport = this.shadowRoot.querySelector('slot[name="viewport"]').assignedNodes()[0];
66
+ if (!viewport) return;
67
+
68
+ let pageEntries = Object.entries(this.pages);
69
+ if (!pageEntries.length) return;
70
+
71
+ let routesList = [];
72
+
73
+ viewport.innerHTML = `
74
+ <static-router>
75
+ ${
76
+ pageEntries.map(
77
+ ([ route, template ]) => {
78
+ let routePath = template.getAttribute('route-path');
79
+ let pageTemplates = this.collection.value.map(
80
+ (modelItem) => {
81
+ let pagePath = renderTemplate(routePath, this.collection.type, modelItem);
82
+ let templateHTML = renderTemplate(template.innerHTML, this.collection.type, modelItem);
83
+ routesList.push(pagePath);
84
+ return `<template slot="page" route-path="${pagePath}">${templateHTML}</template>`
85
+ }
86
+ );
87
+ return pageTemplates.join('');
88
+ }
89
+ ).join('')
90
+ }
91
+ <div slot="viewport"></div>
92
+ </static-router>
93
+ `;
94
+
95
+ if (!hideRouteLogs) console.log(`The following routes have been registered: ${routesList.join(', ')}`);
96
+ }
97
+
98
+ render() {
99
+ let collectionType = this.getAttribute('collection-type');
100
+ let collectionFields = this.getAttribute('collection-fields');
101
+ let collectionView = this.getAttribute('collection-view');
102
+ let collectionPageSize = this.getAttribute('collection-page-size');
103
+ let collectionViewParams = this.getAttribute('collection-view-params');
104
+ let collectionPageOffset = this.getAttribute('collection-page-offset');
105
+ let hideErrorLogs = this.hasAttribute('hide-error-logs');
106
+ let collectionReloadDelay = Number(
107
+ this.getAttribute('collection-reload-delay') || DEFAULT_RELOAD_DELAY
108
+ );
109
+ let viewParams = Object.fromEntries(collectionViewParams.split(',').map((pair) => pair.split('=')));
110
+ if (this.collection) this.collection.destroy();
111
+
112
+ this.collection = new AGCollection({
113
+ socket: this.socket,
114
+ type: collectionType,
115
+ fields: collectionFields.split(',').map(field => field.trim()),
116
+ view: collectionView,
117
+ viewParams,
118
+ pageSize: Number(collectionPageSize || 10),
119
+ pageOffset: Number(collectionPageOffset || 0),
120
+ changeReloadDelay: collectionReloadDelay
121
+ });
122
+
123
+ this.shadowRoot.innerHTML = `
124
+ <slot name="page"></slot>
125
+ <slot name="viewport"></slot>
126
+ `;
127
+
128
+ (async () => {
129
+ await this.collection.listener('load').once();
130
+ if (!this.collection.value.length) {
131
+ this.renderRoutes();
132
+ }
133
+ this.setAttribute('is-loaded', '');
134
+ this.dispatchEvent(new CustomEvent('load', { bubbles: true }));
135
+ })();
136
+
137
+ (async () => {
138
+ for await (let event of this.collection.listener('change')) {
139
+ if (!this.collection.isLoaded) continue;
140
+ this.renderRoutes();
141
+ }
142
+ })();
143
+
144
+ if (!hideErrorLogs) {
145
+ (async () => {
146
+ for await (let { error } of this.collection.listener('error')) {
147
+ console.error(
148
+ `Collection router encountered an error: ${error.message}`
149
+ );
150
+ }
151
+ })();
152
+ }
153
+ }
154
+ }
155
+
156
+ window.customElements.define('collection-router', CollectionRouter);
package/model-input.js CHANGED
@@ -1,13 +1,12 @@
1
- import { getSocket } from './socket.js';
1
+ import { SocketConsumer } from './socket.js';
2
2
  import { getSafeHTML, debouncer } from './utils.js';
3
3
  import AGModel from '/node_modules/ag-model/ag-model.js';
4
4
 
5
- class ModelInput extends HTMLElement {
5
+ class ModelInput extends SocketConsumer {
6
6
  constructor() {
7
7
  super();
8
8
  this.isReady = false;
9
9
  this.inputElement = null;
10
- this.socket = null;
11
10
  }
12
11
  connectedCallback() {
13
12
  this.isReady = true;
@@ -73,6 +72,7 @@ class ModelInput extends HTMLElement {
73
72
  let modelField = this.getAttribute('model-field');
74
73
  let options = this.getAttribute('options');
75
74
  let height = this.getAttribute('height');
75
+ let hideErrorLogs = this.hasAttribute('hide-error-logs');
76
76
  let currentNode = this.parentNode;
77
77
  let model;
78
78
  let isModelLocal = false;
@@ -80,17 +80,26 @@ class ModelInput extends HTMLElement {
80
80
  while (currentNode) {
81
81
  model = currentNode[modelInstanceProperty];
82
82
  if (model) break;
83
- currentNode = currentNode.parentNode;
83
+ currentNode = currentNode.getRootNode().host || currentNode.parentNode;
84
84
  }
85
85
  if (!model) return;
86
86
  } else {
87
- this.socket = getSocket();
87
+ this.socket = this.getSocket();
88
88
  model = new AGModel({
89
89
  socket: this.socket,
90
90
  type: modelType,
91
91
  id: modelId,
92
92
  fields: [ modelField ]
93
93
  });
94
+ if (!hideErrorLogs) {
95
+ (async () => {
96
+ for await (let { error } of model.listener('error')) {
97
+ console.error(
98
+ `Model input encountered an error: ${error.message}`
99
+ );
100
+ }
101
+ })();
102
+ }
94
103
  isModelLocal = true;
95
104
  }
96
105
  if (model.isLoaded) {
package/model-text.js CHANGED
@@ -1,11 +1,11 @@
1
- import { getSocket } from './socket.js';
1
+ import { SocketConsumer } from './socket.js';
2
2
  import { getSafeHTML } from './utils.js';
3
+ import AGModel from '/node_modules/ag-model/ag-model.js';
3
4
 
4
- class ModelText extends HTMLElement {
5
+ class ModelText extends SocketConsumer {
5
6
  connectedCallback() {
6
7
  this.isReady = true;
7
8
  this.destroy = () => {};
8
- this.socket = null;
9
9
  this.render();
10
10
  }
11
11
 
@@ -34,6 +34,7 @@ class ModelText extends HTMLElement {
34
34
  let modelType = this.getAttribute('model-type');
35
35
  let modelId = this.getAttribute('model-id');
36
36
  let modelField = this.getAttribute('model-field');
37
+ let hideErrorLogs = this.hasAttribute('hide-error-logs');
37
38
  let currentNode = this.parentNode;
38
39
  let model;
39
40
  let isModelLocal = false;
@@ -41,17 +42,26 @@ class ModelText extends HTMLElement {
41
42
  while (currentNode) {
42
43
  model = currentNode[modelInstanceProperty];
43
44
  if (model) break;
44
- currentNode = currentNode.parentNode;
45
+ currentNode = currentNode.getRootNode().host || currentNode.parentNode;
45
46
  }
46
47
  if (!model) return;
47
48
  } else {
48
- this.socket = getSocket();
49
+ this.socket = this.getSocket();
49
50
  model = new AGModel({
50
51
  socket: this.socket,
51
52
  type: modelType,
52
53
  id: modelId,
53
54
  fields: [ modelField ]
54
55
  });
56
+ if (!hideErrorLogs) {
57
+ (async () => {
58
+ for await (let { error } of model.listener('error')) {
59
+ console.error(
60
+ `Model input encountered an error: ${error.message}`
61
+ );
62
+ }
63
+ })();
64
+ }
55
65
  isModelLocal = true;
56
66
  }
57
67
  if (model.isLoaded) {
@@ -69,7 +79,7 @@ class ModelText extends HTMLElement {
69
79
  (async () => {
70
80
  for await (let event of changeConsumer) {
71
81
  if (event.resourceField !== modelField || !event.isRemote) continue;
72
- fieldValue = getSafeHTML(model.value[modelField]);
82
+ let fieldValue = getSafeHTML(model.value[modelField]);
73
83
  this.innerHTML = fieldValue;
74
84
  }
75
85
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saasufy-components",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "description": "Components for Saasufy.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -17,8 +17,8 @@
17
17
  "author": "Jonathan Gros-Dubois",
18
18
  "license": "MIT",
19
19
  "dependencies": {
20
- "ag-collection": "^3.2.0",
21
- "ag-model": "^1.3.5",
20
+ "ag-collection": "^3.2.1",
21
+ "ag-model": "^1.3.6",
22
22
  "socketcluster-client": "^19.0.2"
23
23
  },
24
24
  "bugs": {
package/socket.js CHANGED
@@ -2,19 +2,107 @@ import { create } from '/node_modules/socketcluster-client/socketcluster-client.
2
2
 
3
3
  const DEFAULT_AUTH_TOKEN_NAME = 'saasufy.authToken';
4
4
 
5
- let socket;
5
+ let globalSocket;
6
6
 
7
- export function createSocket(options) {
8
- socket = create({
7
+ let urlPartsRegExp = /(^[^:]+):\/\/([^:\/]*)(:[0-9]*)?(\/.*)/;
8
+
9
+ export function createGlobalSocket(options) {
10
+ globalSocket = create({
9
11
  authTokenName: DEFAULT_AUTH_TOKEN_NAME,
10
12
  ...options
11
13
  });
12
- return socket;
14
+ return globalSocket;
15
+ }
16
+
17
+ export function getGlobalSocket(options) {
18
+ if (globalSocket) {
19
+ return globalSocket;
20
+ }
21
+ return createGlobalSocket(options);
13
22
  }
14
23
 
15
- export function getSocket(options) {
16
- if (socket) {
24
+ export class SocketProvider extends HTMLElement {
25
+ constructor() {
26
+ super();
27
+ this.saasufySocket = null;
28
+ this.isReady = false;
29
+ }
30
+
31
+ connectedCallback() {
32
+ this.isReady = true;
33
+ this.getSocket();
34
+ }
35
+
36
+ disconnectedCallback() {
37
+ this.destroySocket();
38
+ }
39
+
40
+ static get observedAttributes() {
41
+ return [
42
+ 'url',
43
+ 'auth-token-name'
44
+ ];
45
+ }
46
+
47
+ attributeChangedCallback(name, oldValue, newValue) {
48
+ if (!this.isReady) return;
49
+ this.destroySocket();
50
+ this.getSocket();
51
+ }
52
+
53
+ destroySocket() {
54
+ if (this.saasufySocket) {
55
+ this.saasufySocket.disconnect();
56
+ }
57
+ }
58
+
59
+ getSocket() {
60
+ if (this.saasufySocket) {
61
+ return this.saasufySocket;
62
+ }
63
+ return this.createSocket();
64
+ }
65
+
66
+ createSocket() {
67
+ let authTokenName = this.getAttribute('auth-token-name') || DEFAULT_AUTH_TOKEN_NAME;
68
+ let url = this.getAttribute('url') || '';
69
+ let urlOptions;
70
+ let matchedList = url.match(urlPartsRegExp);
71
+ if (matchedList) {
72
+ let [ fullMatch, protocolScheme, hostname, port, path ] = matchedList;
73
+ if (port) {
74
+ port = port.replace(/^:/, '');
75
+ }
76
+ urlOptions = {
77
+ protocolScheme,
78
+ hostname,
79
+ port,
80
+ path
81
+ };
82
+ } else {
83
+ urlOptions = {};
84
+ }
85
+ this.saasufySocket = create({
86
+ authTokenName,
87
+ ...urlOptions
88
+ });
89
+
90
+ return this.saasufySocket;
91
+ }
92
+ }
93
+
94
+ export class SocketConsumer extends HTMLElement {
95
+ getSocket() {
96
+ let socket = null;
97
+ let currentNode = this.parentNode;
98
+ while (currentNode) {
99
+ socket = currentNode.saasufySocket;
100
+ if (socket) break;
101
+ currentNode = currentNode.getRootNode().host || currentNode.parentNode;
102
+ }
17
103
  return socket;
18
104
  }
19
- return createSocket(options);
20
105
  }
106
+
107
+ window.customElements.define('socket-provider', SocketProvider);
108
+ window.customElements.define('socket-consumer', SocketConsumer);
@@ -0,0 +1,94 @@
1
+ import { getSafeHTML } from './utils.js';
2
+
3
+ export class StaticRouter extends HTMLElement {
4
+ constructor() {
5
+ super();
6
+ this.attachShadow({ mode: 'open' });
7
+
8
+ this.pages = {};
9
+ this.pageRouteInfos = [];
10
+
11
+ this.hashStartRegex = /^#/;
12
+
13
+ this.handleHashChangeEvent = () => {
14
+ this.renderCurrentPage();
15
+ };
16
+
17
+ this.handleSlotChangeEvent = () => {
18
+ let pageTemplates = this.shadowRoot.querySelector('slot[name="page"]').assignedNodes();
19
+
20
+ for (let template of pageTemplates) {
21
+ let routePath = template.getAttribute('route-path');
22
+ this.pages[routePath] = template;
23
+ }
24
+
25
+ this.pageRouteInfos = Object.keys(this.pages).map((route) => {
26
+ return {
27
+ regExp: new RegExp(`^${route.replace(/\/:[^\/]+/g, '/([^/]*)').replace(/\//g, '\\/')}$`, 'g'),
28
+ route,
29
+ params: [ ...route.matchAll(/:[^\/]*/g) ].map(paramMatch => paramMatch[0].replace(':', ''))
30
+ };
31
+ });
32
+
33
+ this.renderCurrentPage();
34
+ };
35
+ }
36
+
37
+ computeRouteArgs(page, regExp, params) {
38
+ let match = page.matchAll(regExp).next().value || [];
39
+ let argEntries = match
40
+ .slice(1)
41
+ .map((groupMatch, i) => [ params[i], groupMatch ]);
42
+ return Object.fromEntries(argEntries);
43
+ }
44
+
45
+ connectedCallback() {
46
+ window.addEventListener('hashchange', this.handleHashChangeEvent);
47
+ this.shadowRoot.addEventListener('slotchange', this.handleSlotChangeEvent);
48
+ this.render();
49
+ }
50
+
51
+ disconnectedCallback() {
52
+ window.removeEventListener('hashchange', this.handleHashChangeEvent);
53
+ this.shadowRoot.removeEventListener('slotchange', this.handleSlotChangeEvent);
54
+ }
55
+
56
+ renderCurrentPage() {
57
+ let routerViewport = this.shadowRoot.querySelector('slot[name="viewport"]').assignedNodes()[0];
58
+ if (!routerViewport) return;
59
+
60
+ let { hash } = location;
61
+ let pagePath = hash.replace(this.hashStartRegex, '');
62
+
63
+ let { route, regExp, params } = this.pageRouteInfos.find(
64
+ ({ regExp }) => pagePath.match(regExp)
65
+ ) || {};
66
+
67
+ let pageTemplate = this.pages[route];
68
+ if (!pageTemplate) return;
69
+
70
+ let routeArgs;
71
+ if (route) {
72
+ routeArgs = this.computeRouteArgs(pagePath, regExp, params);
73
+ } else {
74
+ routeArgs = {};
75
+ }
76
+
77
+ let templateHTML = pageTemplate.innerHTML;
78
+ for (let [ key, value ] of Object.entries(routeArgs)) {
79
+ let regExp = new RegExp(`{{${key}}}`, 'g');
80
+ templateHTML = templateHTML.replace(regExp, getSafeHTML(value));
81
+ }
82
+
83
+ routerViewport.innerHTML = templateHTML;
84
+ }
85
+
86
+ render() {
87
+ this.shadowRoot.innerHTML = `
88
+ <slot name="page"></slot>
89
+ <slot name="viewport"></slot>
90
+ `;
91
+ }
92
+ }
93
+
94
+ window.customElements.define('static-router', StaticRouter);
package/utils.js CHANGED
@@ -155,6 +155,29 @@ export function createModel(modelOptions) {
155
155
  return model;
156
156
  }
157
157
 
158
+ let templateFormatters = {
159
+ url: (value) => String(value).toLowerCase().replace(/ /g, '-'),
160
+ lowerCase: (value) => String(value).toLowerCase(),
161
+ upperCase: (value) => String(value).toUpperCase(),
162
+ capitalize: (value) => {
163
+ let valueString = String(value);
164
+ return `${valueString.slice(0, 1).toUpperCase()}${valueString.slice(1)}`;
165
+ }
166
+ };
167
+
168
+ export function renderTemplate(templateString, dataType, data) {
169
+ for (let [ field, value ] of Object.entries(data)) {
170
+ let safeValue = getSafeHTML(value);
171
+ for (let [ formatName, formatFunction ] of Object.entries(templateFormatters)) {
172
+ let formatRegExp = new RegExp(`{{${formatName}:${dataType}.${field}}}`, 'g');
173
+ templateString = templateString.replace(formatRegExp, formatFunction(safeValue));
174
+ }
175
+ let regExp = new RegExp(`{{${dataType}.${field}}}`, 'g');
176
+ templateString = templateString.replace(regExp, safeValue);
177
+ }
178
+ return templateString;
179
+ }
180
+
158
181
  export function wait(duration) {
159
182
  return new Promise((resolve) => setTimeout(resolve, duration));
160
183
  }