@miso.ai/client-sdk-ui 1.10.0-beta.0 → 1.10.0-beta.2

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.
@@ -8,22 +8,23 @@ var _commons = require("@miso.ai/commons");
8
8
  var _constants = require("../../constants.js");
9
9
  var _index = require("../../actor/index.js");
10
10
  var _template = _interopRequireDefault(require("../template.js"));
11
+ var _templates = require("../templates.js");
11
12
  var _svgs = require("../../asset/svgs.js");
12
- const _excluded = ["className", "templates"];
13
+ const _excluded = ["className", "templates", "autocomplete"];
13
14
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
14
15
  function _objectWithoutPropertiesLoose(r, e) { if (null == r) return {}; var t = {}; for (var n in r) if ({}.hasOwnProperty.call(r, n)) { if (e.indexOf(n) >= 0) continue; t[n] = r[n]; } return t; }
15
16
  const TYPE = 'search-box';
16
17
  const DEFAULT_CLASSNAME = 'miso-search-box';
18
+ const DEFAULT_AUTOCOMPLETE_CLASSNAME = 'miso-autocomplete';
17
19
  function root(layout) {
18
20
  const {
19
21
  className,
20
22
  role,
21
- templates,
22
23
  options,
23
- workflow
24
+ workflow,
25
+ autocomplete
24
26
  } = layout;
25
27
  const {
26
- autocomplete,
27
28
  placeholder,
28
29
  buttonText
29
30
  } = options;
@@ -33,41 +34,96 @@ function root(layout) {
33
34
  <div class="${className}" ${roleAttr}>
34
35
  <div class="${className}__input-group">
35
36
  <input class="${className}__input" type="text" data-role="input" ${placeholder ? `placeholder="${placeholder}"` : ''}>
36
- <button class="${className}__button" type="submit" data-role="button">${buttonContent}</button>
37
+ <button class="${className}__button" type="submit" data-role="submit">${buttonContent}</button>
37
38
  </div>
38
- ${autocomplete ? templates.autocomplete(layout) : ''}
39
- </div>
40
- `;
39
+ <div class="${autocomplete.className}" data-role="autocomplete"></div>
40
+ </div>`.trim();
41
41
  }
42
- function autocomplete(layout) {
42
+ function completions(layout, items) {
43
+ const {
44
+ templates,
45
+ workflow,
46
+ autocomplete
47
+ } = layout;
48
+ // use options under layout.autocomplete from now on
49
+ layout = Object.assign({
50
+ templates,
51
+ workflow
52
+ }, autocomplete);
53
+ const products = [],
54
+ queries = [];
55
+ for (const item of items) {
56
+ (item.product ? products : queries).push(item);
57
+ }
58
+ return templates.queries(layout, queries) + (queries.length > 0 && products.length > 0 ? `<hr>` : '') + templates.products(layout, products);
59
+ }
60
+ function queries(layout, items) {
43
61
  const {
44
- className,
45
62
  templates
46
63
  } = layout;
47
- return `
48
- <div class="${className}__autocomplete" data-role="autocomplete">
49
- ${templates.suggestionList(layout)}
50
- </div>
51
- `;
64
+ return templates.completionList(layout, 'query', items);
52
65
  }
53
- function suggestionList(layout) {
66
+ function products(layout, items) {
54
67
  const {
55
- className
68
+ templates
56
69
  } = layout;
57
- return `<ol class="${className}__suggestion-list" data-role="suggestion-list"></ol>`;
70
+ return templates.completionList(layout, 'product', items);
71
+ }
72
+ function query(layout) {
73
+ let {
74
+ text,
75
+ text_with_inverted_markups
76
+ } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
77
+ return text_with_inverted_markups || (0, _commons.escapeHtml)(text);
78
+ }
79
+ function product(layout, _ref) {
80
+ let {
81
+ product
82
+ } = _ref;
83
+ const {
84
+ templates
85
+ } = layout;
86
+ const [openTag, closeTag] = _templates.helpers.tagPair(layout, product, {
87
+ role: false
88
+ });
89
+ return [openTag, templates.imageBlock(layout, product), templates.productInfoBlock(layout, product), closeTag].join('');
90
+ }
91
+ function completionList(layout, type, items) {
92
+ const {
93
+ className,
94
+ templates
95
+ } = layout;
96
+ return `
97
+ <ul class="${className}__${type}-list" data-role="${type}-list">
98
+ ${items.map(item => templates.completionItem(layout, type, item)).join('')}
99
+ </ul>`.trim();
58
100
  }
59
- function suggestionItem(layout, value) {
101
+ function completionItem(layout, type, item) {
60
102
  const {
61
- className
103
+ className,
104
+ templates
62
105
  } = layout;
63
- const text = (0, _commons.escapeHtml)(value.text || value);
64
- return `<li class="${className}__suggestion-item" data-role="suggestion-item" tabindex="0">${text}</li>`;
106
+ return `
107
+ <li class="${className}__${type}-item" data-role="${type}-item" data-index="${item._index}" tabindex="0">
108
+ ${templates[type](layout, item)}
109
+ </li>`.trim();
65
110
  }
66
111
  const DEFAULT_TEMPLATES = Object.freeze({
67
112
  root,
68
- autocomplete,
69
- suggestionList,
70
- suggestionItem
113
+ completions,
114
+ completionList,
115
+ completionItem,
116
+ queries,
117
+ products,
118
+ query,
119
+ product,
120
+ imageBlock: _templates.imageBlock,
121
+ productInfoBlock: _templates.productInfoBlock,
122
+ helpers: _templates.helpers
123
+ });
124
+ const DEFAULT_AUTOCOMPLETE_OPTIONS = Object.freeze({
125
+ className: DEFAULT_AUTOCOMPLETE_CLASSNAME,
126
+ debounce: 300
71
127
  });
72
128
  class SearchBoxLayout extends _template.default {
73
129
  static get category() {
@@ -83,34 +139,37 @@ class SearchBoxLayout extends _template.default {
83
139
  return DEFAULT_CLASSNAME;
84
140
  }
85
141
  constructor() {
86
- let _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
142
+ let _ref2 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
87
143
  let {
88
144
  className = DEFAULT_CLASSNAME,
89
- templates
90
- } = _ref,
91
- options = _objectWithoutPropertiesLoose(_ref, _excluded);
145
+ templates,
146
+ autocomplete
147
+ } = _ref2,
148
+ options = _objectWithoutPropertiesLoose(_ref2, _excluded);
92
149
  super(Object.assign({
93
150
  className,
94
151
  templates: Object.assign({}, DEFAULT_TEMPLATES, templates)
95
152
  }, options));
153
+ (0, _commons.defineValues)(this, {
154
+ autocomplete: Object.assign({}, DEFAULT_AUTOCOMPLETE_OPTIONS, autocomplete)
155
+ });
96
156
  this._contexts = new WeakMap();
97
- this._suggestionItems = new WeakMap(); // TODO: put them on elements as layout can be potentially replaced
157
+ this._prevInput = undefined;
158
+ this._debounce = (0, _commons.debounce)(this.autocomplete.debounce);
98
159
  }
99
160
  initialize(view) {
100
- const {
101
- autocomplete
102
- } = this.options;
103
161
  this._view = view;
104
162
  const {
105
163
  proxyElement,
106
164
  hub
107
165
  } = view;
108
- this._unsubscribes = [...this._unsubscribes, proxyElement.on('keydown', e => this._handleKeyDown(e)), proxyElement.on('click', e => this._handleClick(e))];
109
- if (autocomplete) {
110
- this._unsubscribes.push(hub.on(_index.fields.suggestions(), () => this._handleInput()));
111
- }
166
+ this._unsubscribes = [...this._unsubscribes, proxyElement.on('input', e => this._syncInput(e)), proxyElement.on('keydown', e => this._handleKeyDown(e)), proxyElement.on('click', e => this._handleClick(e)), proxyElement.on('focusin', e => this._handleFocusIn(e)), proxyElement.on('focusout', e => this._handleFocusOut(e))];
167
+ this._unsubscribes.push(hub.on(_index.fields.completions(), () => view.refresh({
168
+ force: true
169
+ })));
112
170
  }
113
171
  focus() {
172
+ // TODO: support blur as well
114
173
  if (this._element) {
115
174
  this._context().focus();
116
175
  } else {
@@ -118,6 +177,12 @@ class SearchBoxLayout extends _template.default {
118
177
  this._focusRequested = true;
119
178
  }
120
179
  }
180
+ open() {
181
+ this._element.classList.add('open');
182
+ }
183
+ close() {
184
+ this._element.classList.remove('open');
185
+ }
121
186
  set value(value) {
122
187
  if (this._element) {
123
188
  this._context().value = value;
@@ -125,22 +190,15 @@ class SearchBoxLayout extends _template.default {
125
190
  this._valueRequested = value;
126
191
  }
127
192
  }
128
- _preprocess(_ref2) {
193
+ _preprocess(_ref3) {
129
194
  let {
130
195
  state
131
- } = _ref2;
196
+ } = _ref3;
132
197
  state = super._preprocess({
133
198
  state
134
199
  });
135
- const {
136
- autocomplete
137
- } = this.options;
138
- if (!autocomplete) {
139
- return state;
140
- }
141
- const suggestions = this._view.hub.states[_index.fields.suggestions()];
142
200
  return Object.assign({}, state, {
143
- suggestions
201
+ completions: this._view.hub.states[_index.fields.completions()]
144
202
  });
145
203
  }
146
204
  _render(element, data, controls) {
@@ -148,58 +206,46 @@ class SearchBoxLayout extends _template.default {
148
206
  // only render the template once, and also skip if there are children already to allow customized DOM
149
207
  super._render(element, data, controls);
150
208
  }
151
- this._setupAutocomplete(element);
152
- this._renderSuggestions(element, data, controls);
209
+ this._updateCompletions(element, data, controls);
153
210
  this._fullfillFocusRequest(element);
154
211
  this._fullfillValueRequest(element);
155
212
  }
156
- _setupAutocomplete(element) {
157
- const {
158
- autocomplete
159
- } = this.options;
160
- if (!autocomplete) {
161
- return;
162
- }
163
- this._context(element).setupAutocomplete();
164
- }
165
- _renderSuggestions(element, _ref3) {
213
+ _updateCompletions(element, _ref4, _ref5) {
166
214
  let {
167
- state
168
- } = _ref3;
215
+ state,
216
+ rendered
217
+ } = _ref4;
169
218
  let {
170
219
  writeToState
171
- } = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
172
- const {
173
- autocomplete
174
- } = this.options;
175
- if (!autocomplete) {
220
+ } = _ref5;
221
+ if (!state.completions) {
176
222
  return;
177
223
  }
178
224
  const {
179
- suggestionListElement,
180
- autocompleteElement
181
- } = this._context(element);
182
- if (!suggestionListElement) {
183
- // not actually rendered
184
- writeToState && writeToState({
185
- suggestions: {
186
- value: []
187
- }
188
- });
225
+ status = _constants.STATUS.INITIAL,
226
+ index,
227
+ value: completions
228
+ } = state.completions;
229
+ if (rendered && rendered.completions.index === index && rendered.completions.status === status) {
189
230
  return;
190
231
  }
191
-
192
- // TODO: incremental
232
+ const context = this._context(element);
193
233
  const {
194
- suggestions = {}
195
- } = state;
196
- const suggestionItems = suggestions.value || [];
197
- autocompleteElement.classList[suggestionItems.length === 0 ? 'add' : 'remove']('empty');
198
- suggestionListElement.innerHTML = suggestionItems.map(suggestion => this.templates.suggestionItem(this, suggestion)).join('');
199
- let i = 0;
200
- for (const itemElement of suggestionListElement.children) {
201
- this._suggestionItems.set(itemElement, suggestionItems[i++]);
234
+ autocompleteElement
235
+ } = context;
236
+ if (!autocompleteElement) {
237
+ return;
202
238
  }
239
+ const items = context.trackItems(completions);
240
+ element.classList[items.length > 0 ? 'add' : 'remove']('nonempty');
241
+ autocompleteElement.setAttribute('data-status', status);
242
+ autocompleteElement.innerHTML = this.templates.completions(this, items);
243
+ writeToState({
244
+ completions: {
245
+ index,
246
+ status
247
+ }
248
+ });
203
249
  }
204
250
  _fullfillFocusRequest(element) {
205
251
  if (!element || !this._focusRequested) {
@@ -226,56 +272,108 @@ class SearchBoxLayout extends _template.default {
226
272
  }
227
273
  return context;
228
274
  }
229
- _handleKeyDown(_ref4) {
275
+ _syncInput() {
276
+ const {
277
+ inputElement
278
+ } = this._context();
279
+ if (!inputElement) {
280
+ return;
281
+ }
282
+ const value = inputElement.value.trim();
283
+ if (this._prevInput === value) {
284
+ return;
285
+ }
286
+ this._prevInput = value;
287
+ const self = this;
288
+ this._debounce(() => self._view.hub.update(_index.fields.input(), {
289
+ value
290
+ }));
291
+ }
292
+ _handleFocusIn(_ref6) {
293
+ let {
294
+ target
295
+ } = _ref6;
296
+ if (!this._element) {
297
+ return;
298
+ }
299
+ if (target.matches('[data-role="input"]')) {
300
+ this._syncInput();
301
+ this.open();
302
+ }
303
+ }
304
+ _handleFocusOut(_ref7) {
305
+ let {
306
+ relatedTarget
307
+ } = _ref7;
308
+ if (!this._element) {
309
+ return;
310
+ }
311
+ if (!relatedTarget || !(0, _commons.findInAncestors)(relatedTarget, element => element === this._element || undefined)) {
312
+ this.close();
313
+ }
314
+ }
315
+ _handleKeyDown(_ref8) {
230
316
  let {
231
317
  key,
232
318
  target,
233
319
  isComposing
234
- } = _ref4;
320
+ } = _ref8;
235
321
  if (!isComposing && key === 'Enter' && target.matches('input')) {
236
322
  this._submit(target.value);
237
323
  target.blur();
238
324
  }
325
+ // TODO: select completion items with arrow keys
239
326
  }
240
- _handleClick(_ref5) {
241
- let {
327
+ _handleClick(event) {
328
+ const {
242
329
  target
243
- } = _ref5;
330
+ } = event;
244
331
  const {
245
332
  inputElement
246
333
  } = this._context();
247
- for (let element = target; element && element !== this._view.element; element = element.parentElement) {
248
- if (element.matches('[type="submit"]') || element.matches('[data-role="button"]')) {
334
+ for (let element = target; element && element !== this._element; element = element.parentElement) {
335
+ if (element.matches('[data-role="submit"]') || element.matches('[data-role="button"]') || element.matches('[type="submit"]')) {
249
336
  if (inputElement) {
250
337
  inputElement.blur();
251
338
  this._submit(inputElement.value);
252
339
  }
253
340
  return;
254
341
  }
255
- }
256
- const {
257
- autocomplete
258
- } = this.options;
259
- if (!autocomplete) {
260
- return;
261
- }
262
- for (let element = target; element && element !== this._view.element; element = element.parentElement) {
263
- const suggestion = this._suggestionItems.get(element);
264
- if (!suggestion) {
265
- continue;
342
+ if (element.matches('[data-role="query-item"]')) {
343
+ const index = element.getAttribute('data-index');
344
+ const item = this._context().data.get(parseInt(index));
345
+ const value = item && item.text;
346
+ if (value) {
347
+ if (inputElement) {
348
+ inputElement.value = value;
349
+ }
350
+ this._view._events.emit('select', Object.freeze({
351
+ value: item,
352
+ index,
353
+ domEvent: event
354
+ }));
355
+ if (!event.defaultPrevented) {
356
+ this._submit(value);
357
+ }
358
+ }
359
+ this.close();
360
+ return;
266
361
  }
267
- const value = suggestion.text || suggestion;
268
- if (inputElement) {
269
- inputElement.value = value;
362
+ if (element.matches('[data-role="product-item"]')) {
363
+ this._view._events.emit('select', Object.freeze({
364
+ value: item,
365
+ index,
366
+ domEvent: event
367
+ }));
368
+ return;
270
369
  }
271
- this._submit(value);
272
- return;
273
370
  }
274
371
  }
275
372
  async _submit(value) {
276
373
  if (!value) {
277
374
  return;
278
375
  }
376
+ this.close();
279
377
  // TODO: q -> value
280
378
  this._view.hub.trigger(_index.fields.query(), {
281
379
  q: value
@@ -290,6 +388,7 @@ class Context {
290
388
  constructor(element) {
291
389
  this._element = element;
292
390
  this._cache = {};
391
+ this._data = new Map();
293
392
  }
294
393
  get inputElement() {
295
394
  return this._get('input');
@@ -297,27 +396,26 @@ class Context {
297
396
  get autocompleteElement() {
298
397
  return this._get('autocomplete');
299
398
  }
300
- get suggestionListElement() {
301
- return this._get('suggestion-list');
399
+ get data() {
400
+ return this._data;
302
401
  }
303
- setupAutocomplete() {
304
- if (!this._element || this._autocompleteSetup) {
305
- return;
306
- }
307
- this._autocompleteSetup = true;
308
- const {
309
- inputElement,
310
- autocompleteElement
311
- } = this;
312
- if (inputElement && autocompleteElement) {
313
- // TODO: should we clean up
314
- inputElement.addEventListener('focus', () => {
315
- autocompleteElement.classList.add('open');
316
- });
317
- inputElement.addEventListener('blur', () => {
318
- autocompleteElement.classList.remove('open');
319
- });
402
+ trackItems(completions) {
403
+ let index = 0;
404
+ const data = this._data = new Map();
405
+ const items = [];
406
+ for (const field in completions) {
407
+ for (const item of completions[field]) {
408
+ if (item.product && !item.product.url) {
409
+ continue; // product without URL is not clickable
410
+ }
411
+ item._index = index;
412
+ item._field = field;
413
+ data.set(index, item);
414
+ items.push(item);
415
+ index++;
416
+ }
320
417
  }
418
+ return items;
321
419
  }
322
420
  focus() {
323
421
  if (!this._element) {
@@ -84,7 +84,12 @@ const DEFAULT_TEMPLATES = Object.freeze({
84
84
  list,
85
85
  ordered: false,
86
86
  items,
87
- item
87
+ item,
88
+ productInfoBlock: _templates.productInfoBlock,
89
+ articleInfoBlock: _templates.articleInfoBlock,
90
+ imageBlock: _templates.imageBlock,
91
+ indexBlock: _templates.indexBlock,
92
+ helpers: _templates.helpers
88
93
  });
89
94
  class CollectionLayout extends _template.default {
90
95
  static get category() {
@@ -4,7 +4,12 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.article = article;
7
+ exports.articleInfoBlock = articleInfoBlock;
8
+ exports.helpers = void 0;
9
+ exports.imageBlock = imageBlock;
10
+ exports.indexBlock = indexBlock;
7
11
  exports.product = product;
12
+ exports.productInfoBlock = productInfoBlock;
8
13
  exports.question = question;
9
14
  exports.requiresImplementation = requiresImplementation;
10
15
  exports.unimplemented = unimplemented;
@@ -28,19 +33,19 @@ function product(layout, state, data, meta) {
28
33
  const {
29
34
  templates
30
35
  } = layout;
31
- const [openTag, closeTag] = renderTagPair(layout, data);
32
- return [openTag, (templates.imageBlock || renderImageBlock)(layout, data, meta), (templates.infoBlock || productInfoBlock)(layout, data, meta), (templates.indexBlock || renderIndexBlock)(layout, data, meta), closeTag].join('');
36
+ const [openTag, closeTag] = tagPair(layout, data);
37
+ return [openTag, (templates.imageBlock || imageBlock)(layout, data, meta), (templates.infoBlock || templates.productInfoBlock || productInfoBlock)(layout, data, meta), closeTag].join('');
33
38
  }
34
39
  function question(layout, state, data) {
35
- const [openTag, closeTag] = renderTagPair(layout, data);
40
+ const [openTag, closeTag] = tagPair(layout, data);
36
41
  return `${openTag}${data.value || data.text || data}${closeTag}`;
37
42
  }
38
43
  function article(layout, state, data, meta) {
39
44
  const {
40
45
  templates
41
46
  } = layout;
42
- const [openTag, closeTag] = renderTagPair(layout, data);
43
- return [openTag, (templates.imageBlock || renderImageBlock)(layout, data, meta), (templates.infoBlock || articleInfoBlock)(layout, data, meta), (templates.indexBlock || renderIndexBlock)(layout, data, meta), closeTag].join('');
47
+ const [openTag, closeTag] = tagPair(layout, data);
48
+ return [openTag, (templates.imageBlock || imageBlock)(layout, data, meta), (templates.infoBlock || templates.articleInfoBlock || articleInfoBlock)(layout, data, meta), (templates.indexBlock || indexBlock)(layout, data, meta), closeTag].join('');
44
49
  }
45
50
  function productInfoBlock(_ref, _ref2) {
46
51
  let {
@@ -85,7 +90,7 @@ function articleInfoBlock(_ref3, _ref4) {
85
90
  content += `<div class="${className}__item-title">${(0, _commons.escapeHtml)(title)}</div>`;
86
91
  }
87
92
  if (date) {
88
- content += `<div class="${className}__item-date">${renderDate(date, templates.date)}</div>`;
93
+ content += `<div class="${className}__item-date">${formatDate(date, templates.date)}</div>`;
89
94
  }
90
95
  if (snippet) {
91
96
  content += `<div class="${className}__item-snippet">${snippet}</div>`;
@@ -94,17 +99,44 @@ function articleInfoBlock(_ref3, _ref4) {
94
99
  }
95
100
  return `<div class="${className}__item-info-container">${content}</div>`;
96
101
  }
102
+ function imageBlock(_ref5, _ref6) {
103
+ let {
104
+ className
105
+ } = _ref5;
106
+ let {
107
+ cover_image
108
+ } = _ref6;
109
+ if (!cover_image) {
110
+ return '';
111
+ }
112
+ const img = `<img class="${className}__item-cover-image" src="${cover_image}">`;
113
+ return `<div class="${className}__item-cover-image-container">${img}</div>`;
114
+ }
115
+ function indexBlock(_ref7, data, _ref8) {
116
+ let {
117
+ className
118
+ } = _ref7;
119
+ let {
120
+ index
121
+ } = _ref8;
122
+ const i = index + 1;
123
+ return `<div class="${className}__item-index-container"><span class="${className}__item-index miso-citation-index" data-index="${i}"></span></div>`;
124
+ }
97
125
 
98
126
  // helpers //
99
- function renderTagPair(_ref5, _ref6) {
127
+ function tagPair(_ref9, _ref10) {
100
128
  let {
101
129
  className,
102
130
  options = {}
103
- } = _ref5;
131
+ } = _ref9;
104
132
  let {
105
133
  product_id,
106
134
  url
107
- } = _ref6;
135
+ } = _ref10;
136
+ let {
137
+ classSuffix = 'item-body',
138
+ role = 'item'
139
+ } = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
108
140
  const {
109
141
  link = {}
110
142
  } = options;
@@ -115,30 +147,8 @@ function renderTagPair(_ref5, _ref6) {
115
147
  const tag = url ? 'a' : 'div';
116
148
  const urlAttrs = url ? `href="${url}" target="${target}" rel="${rel}"` : '';
117
149
  const productAttrs = product_id ? `${_constants.ATTR_DATA_MISO_PRODUCT_ID}="${product_id}"` : '';
118
- return [`<${tag} class="${className}__item-body" data-role="item" ${productAttrs} ${urlAttrs}>`, `</${tag}>`];
119
- }
120
- function renderImageBlock(_ref7, _ref8) {
121
- let {
122
- className
123
- } = _ref7;
124
- let {
125
- cover_image
126
- } = _ref8;
127
- if (!cover_image) {
128
- return '';
129
- }
130
- const img = `<img class="${className}__item-cover-image" src="${cover_image}">`;
131
- return `<div class="${className}__item-cover-image-container">${img}</div>`;
132
- }
133
- function renderIndexBlock(_ref9, data, _ref10) {
134
- let {
135
- className
136
- } = _ref9;
137
- let {
138
- index
139
- } = _ref10;
140
- const i = index + 1;
141
- return `<div class="${className}__item-index-container"><span class="${className}__item-index miso-citation-index" data-index="${i}"></span></div>`;
150
+ const roleAttrs = role ? `data-role="${role}"` : '';
151
+ return [`<${tag} class="${className}__${classSuffix}" ${roleAttrs} ${productAttrs} ${urlAttrs}>`, `</${tag}>`];
142
152
  }
143
153
  const DEFAULT_DATE_OPTIONS = Object.freeze({
144
154
  locale: 'en-US',
@@ -146,7 +156,7 @@ const DEFAULT_DATE_OPTIONS = Object.freeze({
146
156
  month: 'short',
147
157
  day: 'numeric'
148
158
  });
149
- function renderDate(date) {
159
+ function formatDate(date) {
150
160
  let fn = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : DEFAULT_DATE_OPTIONS;
151
161
  switch (typeof fn) {
152
162
  case 'function':
@@ -158,4 +168,9 @@ function renderDate(date) {
158
168
  default:
159
169
  throw new Error(`Invalid date format: ${fn}`);
160
170
  }
161
- }
171
+ }
172
+ const helpers = exports.helpers = Object.freeze({
173
+ tagPair,
174
+ formatDate,
175
+ escapeHtml: _commons.escapeHtml
176
+ });