coralite 0.0.0-development

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.
package/lib/parse.js ADDED
@@ -0,0 +1,820 @@
1
+ import { Parser } from 'htmlparser2'
2
+ import { aggregate } from './html-module.js'
3
+ import vm from 'node:vm'
4
+ import { invalidCustomTags, validTags } from './tags.js'
5
+
6
+ /**
7
+ * @import {Module} from 'node:vm'
8
+ * @import {
9
+ * HTMLData,
10
+ * CoraliteDocument,
11
+ * CoralitePath,
12
+ * CoraliteToken,
13
+ * CoraliteModule,
14
+ * CoraliteTextNode,
15
+ * CoraliteElement,
16
+ * CoraliteSlotElement,
17
+ * CoraliteModuleSlotElement,
18
+ * CoraliteDocumentTokens,
19
+ * CoraliteDocumentRoot,
20
+ * CoraliteDirective} from '#types'
21
+ */
22
+
23
+ const customElementTagRegExp = /^[^-].*[-._a-z0-9\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\u{10000}-\u{EFFFF}]*$/ui
24
+
25
+ const customElementTagTokenRegExp = /^[^-].*[-._a-z0-9\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\u{10000}-\u{EFFFF}\{\}]*$/ui
26
+
27
+ /**
28
+ * Parses HTML content into a Coralite document structure.
29
+ *
30
+ * @param {HTMLData} html - The HTML data containing the content to parse
31
+ * @param {CoralitePath} path - The path object containing the file path information
32
+ * @returns {CoraliteDocument} An object representing the parsed document structure
33
+ *
34
+ * @example
35
+ * ```javascript
36
+ * // Example usage:
37
+ * const html = {
38
+ * name: 'index.html',
39
+ * parentPath: 'path/to/parent',
40
+ * content: '<div>Content</div>'
41
+ * };
42
+ *
43
+ * const path = {
44
+ * pages: 'path/to/pages',
45
+ * components: 'path/to/components'
46
+ * };
47
+ *
48
+ * const document = parseHTMLDocument(html, path);
49
+ * // document.root will contain parsed elements and text nodes
50
+ * ```
51
+ */
52
+ export function parseHTMLDocument (html, path) {
53
+ // root element reference
54
+ /** @type {CoraliteDocumentRoot} */
55
+ const root = {
56
+ type: 'root',
57
+ children: []
58
+ }
59
+ const rootChildren = root.children
60
+ // stack to keep track of current element hierarchy
61
+ const stack = []
62
+ const customElements = []
63
+ /** @type {Object.<string, CoraliteSlotElement[]>} */
64
+ const customElementSlots = {}
65
+
66
+ const parser = new Parser({
67
+ onprocessinginstruction (name, data) {
68
+ rootChildren.push({
69
+ type: 'directive',
70
+ name,
71
+ data
72
+ })
73
+ },
74
+ onopentag (originalName, attributes) {
75
+ const element = createElement(originalName, attributes, customElements, stack, rootChildren)
76
+
77
+ if (attributes.slot) {
78
+ const customElement = customElements[customElements.length - 1]
79
+ const slot = {
80
+ name: attributes.slot,
81
+ customElement,
82
+ element
83
+ }
84
+
85
+ if (!customElementSlots[customElement.name]) {
86
+ customElementSlots[customElement.name] = [slot]
87
+ } else {
88
+ customElementSlots[customElement.name].unshift(slot)
89
+ }
90
+
91
+ // remove slot attribute
92
+ delete attributes.slot
93
+ }
94
+ },
95
+ ontext (text) {
96
+ if (text.trim()) {
97
+ createTextNode(text, stack)
98
+ }
99
+ },
100
+ onclosetag () {
101
+ // remove current element from stack as we're done with its children
102
+ stack.pop()
103
+ },
104
+ oncomment (comment) {
105
+ stack[stack.length - 1].children.push({
106
+ type: 'comment',
107
+ data: comment,
108
+ parent: stack[stack.length - 1]
109
+ })
110
+ }
111
+ })
112
+
113
+ parser.write(html.content)
114
+ parser.end()
115
+
116
+ return {
117
+ name: html.name,
118
+ parentPath: html.parentPath,
119
+ root,
120
+ customElements,
121
+ customElementSlots,
122
+ path
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Parses HTML string containing meta tags and extracts associated metadata.
128
+ *
129
+ * @param {string} string - HTML content containing meta tags
130
+ * @returns {Object.<string, CoraliteToken[]>}
131
+ *
132
+ * @example
133
+ * ```javascript
134
+ * // Example usage:
135
+ * const html = `<meta name="title" content="Finding Nemo">`;
136
+ * const meta = parseHTMLMeta(html);
137
+ *
138
+ * // Output will be an object like:
139
+ * //{
140
+ * // "title": [ { name: 'title', content: 'Finding Nemo' } ],
141
+ * //}
142
+ * ```
143
+ */
144
+ export function parseHTMLMeta (string) {
145
+ // stack to keep track of current element hierarchy
146
+ const stack = []
147
+ /** @type {Object.<string, CoraliteToken[]>} */
148
+ const meta = {}
149
+
150
+ const parser = new Parser({
151
+ onopentag (name, attributes) {
152
+ if (name === 'meta') {
153
+ if (attributes.content) {
154
+ if (attributes.property) {
155
+ addMetadata(meta, attributes.property, attributes.content)
156
+ }
157
+
158
+ if (attributes.name) {
159
+ addMetadata(meta, attributes.name, attributes.content)
160
+ }
161
+ }
162
+ }
163
+ },
164
+ onclosetag (name) {
165
+ if (name === 'head') {
166
+ // end on closing head tag
167
+ return parser.end()
168
+ }
169
+ // remove current element from stack as we're done with its children
170
+ stack.pop()
171
+ },
172
+ oncomment (comment) {
173
+ stack[stack.length - 1].children.push({
174
+ type: 'comment',
175
+ data: comment,
176
+ parent: stack[stack.length - 1]
177
+ })
178
+ }
179
+ })
180
+
181
+ parser.write(string)
182
+ parser.end()
183
+
184
+ return meta
185
+ }
186
+
187
+
188
+ /**
189
+ * Parses HTML string containing meta tags or generates Coralite module structure from markup.
190
+ *
191
+ * @param {string} string - HTML content containing meta tags or module markup
192
+ * @returns {CoraliteModule} - Parsed module information, including template, script, tokens, and slot configurations
193
+ *
194
+ * @example
195
+ * ```javascript
196
+ * // Example usage:
197
+ * const html = `<template id="home">
198
+ * <slot name="default">Hello</slot>
199
+ * </template>`;
200
+ * const module = parseModule(html);
201
+ *
202
+ * // Module object structure will be:
203
+ * //{
204
+ * // id: 'home',
205
+ * // template: { ... },
206
+ * // script: undefined,
207
+ * // tokens: [],
208
+ * // customElements: [],
209
+ * // slotElements: {
210
+ * // 'home': {
211
+ * // 'default': {
212
+ * // name: 'slot',
213
+ * // element: {}
214
+ * // }
215
+ * // }
216
+ * //}
217
+ * ```
218
+ */
219
+ export function parseModule (string) {
220
+ // root element reference
221
+ const root = []
222
+ // stack to keep track of current element hierarchy
223
+ const stack = []
224
+ const customElements = []
225
+ /** @type {Object.<string, Object.<string,CoraliteModuleSlotElement>>} */
226
+ const slotElements = {}
227
+
228
+ /** @type {CoraliteDocumentTokens} */
229
+ const documentTokens = {
230
+ attributes: [],
231
+ textNodes: []
232
+ }
233
+ let templateId = ''
234
+
235
+ const parser = new Parser({
236
+ onopentag (originalName, attributes) {
237
+ const element = createElement(originalName, attributes, customElements, stack, root)
238
+ const attributeNames = Object.keys(attributes)
239
+
240
+ // collect tokens
241
+ if (attributeNames.length) {
242
+ for (let i = 0; i < attributeNames.length; i++) {
243
+ const name = attributeNames[i]
244
+ const tokens = getTokensFromString(attributes[name])
245
+
246
+ // store attribute tokens
247
+ if (tokens.length) {
248
+ documentTokens.attributes.push({
249
+ name,
250
+ tokens,
251
+ element
252
+ })
253
+ }
254
+ }
255
+ }
256
+
257
+ if (element.name === 'template') {
258
+ if (!attributes.id) {
259
+ throw new Error('Template requires an "id"')
260
+ }
261
+
262
+ let idHasToken = false
263
+ // check if template id contains a token
264
+ for (let i = 0; i < documentTokens.attributes.length; i++) {
265
+ const token = documentTokens.attributes[i]
266
+
267
+ if (token.name === 'id') {
268
+ idHasToken = true
269
+ break
270
+ }
271
+ }
272
+
273
+ if (!customElementTagRegExp.test(attributes.id)
274
+ || (idHasToken && !customElementTagTokenRegExp.test(attributes.id))) {
275
+ throw new Error('Invalid template id: "' + originalName + '" it must match following the pattern https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name')
276
+ }
277
+
278
+ templateId = attributes.id
279
+ } else if (element.name === 'slot') {
280
+ const name = attributes.name || 'default'
281
+
282
+ if (slotElements[templateId] && slotElements[templateId][name]) {
283
+ throw new Error('Slot names must be unique: "' + name + '"')
284
+ }
285
+
286
+ const slot = {
287
+ name,
288
+ element
289
+ }
290
+
291
+ if (!slotElements[templateId]) {
292
+ slotElements[templateId] = {
293
+ [name]: slot
294
+ }
295
+ } else {
296
+ slotElements[templateId][name] = slot
297
+ }
298
+ }
299
+ },
300
+ ontext (text) {
301
+ if (text.trim()) {
302
+ const textNode = createTextNode(text, stack)
303
+ const tokens = getTokensFromString(text)
304
+
305
+ // store tokens
306
+ if (tokens.length) {
307
+ documentTokens.textNodes.push({
308
+ tokens,
309
+ textNode
310
+ })
311
+ }
312
+ }
313
+ },
314
+ onclosetag () {
315
+ // remove current element from stack as we're done with its children
316
+ stack.pop()
317
+ },
318
+ oncdatastart (csb) {
319
+ console.log(csb)
320
+ },
321
+
322
+ oncomment (comment) {
323
+ stack[stack.length - 1].children.push({
324
+ type: 'comment',
325
+ data: comment,
326
+ parent: stack[stack.length - 1]
327
+ })
328
+ }
329
+ })
330
+
331
+ parser.write(string)
332
+ parser.end()
333
+
334
+ /** @type {CoraliteElement} */
335
+ let template
336
+ let script
337
+
338
+ for (let i = 0; i < root.length; i++) {
339
+ const node = root[i]
340
+
341
+ if (node.type === 'text') {
342
+ continue
343
+ }
344
+
345
+ if (node.name === 'template') {
346
+ if (template) {
347
+ throw new Error('One template element is permitted')
348
+ }
349
+
350
+ template = node
351
+
352
+ } else if (node.name == 'script') {
353
+ if (node.attribs.type !== 'module') {
354
+ throw new Error('Script tag must contain the `type="module"` attribute')
355
+ }
356
+
357
+ script = node.children[0].data
358
+ }
359
+ }
360
+
361
+ if (!template) {
362
+ throw new Error('Template element is missing')
363
+ }
364
+
365
+ return {
366
+ id: template.attribs.id,
367
+ template,
368
+ script,
369
+ tokens: documentTokens,
370
+ customElements,
371
+ slotElements
372
+ }
373
+ }
374
+
375
+ /**
376
+ * @param {Object} options
377
+ * @param {string} options.id - id - Unique identifier for the component
378
+ * @param {Object.<string, (string | (CoraliteTextNode | CoraliteElement)[])>} options.values - Token values available for replacement
379
+ * @param {Object.<string, CoraliteModuleSlotElement[]>} [options.customElementSlots = {}] - Custom slots and their configurations for the component
380
+ * @param {Object.<string, CoraliteModule>} options.components - Mapping of component IDs to their module definitions
381
+ * @param {CoraliteDocument} options.document - Current document being processed
382
+ * @returns {Promise<CoraliteElement>}
383
+ *
384
+ * @example
385
+ * ```javascript
386
+ * // Example usage:
387
+ * const component = await createComponent({
388
+ * id: 'my-component',
389
+ * values: {
390
+ * 'some-token': 'value-for-token'
391
+ * },
392
+ * customElementSlots: {
393
+ * 'slot-name': [
394
+ * { name: 'sub-slot', element: 'p' }
395
+ * ]
396
+ * },
397
+ * components: {
398
+ * 'my-component': {
399
+ * id: 'my-component',
400
+ * template: someTemplate,
401
+ * script: someScript,
402
+ * tokens: someTokens
403
+ * }
404
+ * },
405
+ * document: documentInstance
406
+ * })
407
+ * ```
408
+ */
409
+ export async function createComponent ({
410
+ id,
411
+ values,
412
+ customElementSlots = {},
413
+ components,
414
+ document
415
+ }) {
416
+ let component = components[id]
417
+
418
+ if (!component) {
419
+ throw new Error('Could not find component: "' + id +'"')
420
+ }
421
+
422
+ component = structuredClone(component)
423
+ const template = component.template
424
+ const computedTokens = []
425
+
426
+ for (let i = 0; i < component.tokens.attributes.length; i++) {
427
+ const item = component.tokens.attributes[i]
428
+
429
+ for (let i = 0; i < item.tokens.length; i++) {
430
+ const token = item.tokens[i]
431
+ let value = values[token.name]
432
+
433
+ if (value == null) {
434
+ if (component.script) {
435
+ computedTokens.push({
436
+ type: 'element',
437
+ node: item.element,
438
+ name: token.name,
439
+ content: token.content
440
+ })
441
+
442
+ continue
443
+ } else {
444
+ console.error('Token "' + token.name +'" was empty used on "' + component.id + '"')
445
+ value = ''
446
+ }
447
+ }
448
+
449
+ // replace token with value
450
+ item.element.attribs[item.name] = item.element.attribs[item.name].replace(token.content, value)
451
+ }
452
+ }
453
+
454
+ for (let i = 0; i < component.tokens.textNodes.length; i++) {
455
+ const item = component.tokens.textNodes[i]
456
+
457
+ for (let i = 0; i < item.tokens.length; i++) {
458
+ const token = item.tokens[i]
459
+ let value = values[token.name]
460
+
461
+ if (value == null) {
462
+ if (component.script) {
463
+ computedTokens.push({
464
+ type: 'textNode',
465
+ node: item.textNode,
466
+ name: token.name,
467
+ content: token.content
468
+ })
469
+
470
+ continue
471
+ } else {
472
+ console.error('Token "' + token.name +'" was empty used on "' + component.id + '"')
473
+ value = ''
474
+ }
475
+ }
476
+
477
+ // replace token with value
478
+ item.textNode.data = item.textNode.data.replace(token.content, value)
479
+ }
480
+ }
481
+
482
+ // merge values from component script
483
+ if (component.script) {
484
+ const computedValues = await parseScript(component, values, components, document)
485
+
486
+ values = Object.assign(values, computedValues)
487
+
488
+ for (let i = 0; i < computedTokens.length; i++) {
489
+ const computedToken = computedTokens[i]
490
+ const node = computedToken.node
491
+ const value = values[computedToken.name]
492
+
493
+ if (computedToken.type === 'attribute') {
494
+ // replace token string
495
+ node.attribs[computedToken.name] = node.attribs[computedToken.name].replace(computedToken.content, value)
496
+ } else {
497
+ if (Array.isArray(value)) {
498
+ // inject nodes
499
+ const textSplit = node.data.split(computedToken.content)
500
+ const childIndex = node.parent.children.indexOf(node)
501
+
502
+ node.parent.children.splice(childIndex, 1,
503
+ {
504
+ type: 'text',
505
+ data: textSplit[0],
506
+ parent: node.parent
507
+ },
508
+ ...value,
509
+ {
510
+ type: 'text',
511
+ data: textSplit[1],
512
+ parent: node.parent
513
+ }
514
+ )
515
+ } else {
516
+ // replace token string
517
+ node.data = node.data.replace(computedToken.content, value)
518
+ }
519
+ }
520
+ }
521
+ }
522
+
523
+ // replace nested customElements
524
+ const customElements = component.customElements
525
+ let childIndex
526
+
527
+ for (let i = 0; i < customElements.length; i++) {
528
+ const customElement = customElements[i]
529
+ const component = await createComponent({
530
+ id: customElement.name,
531
+ values: Object.assign(values, customElement.attribs),
532
+ customElementSlots,
533
+ components,
534
+ document
535
+ })
536
+ const children = customElement.parent.children
537
+
538
+ if (!childIndex) {
539
+ childIndex = customElement.parentChildIndex
540
+ } else {
541
+ childIndex = children.indexOf(customElement, customElement.parentChildIndex)
542
+ }
543
+
544
+ // replace custom element with component
545
+ children.splice(childIndex, 1, ...component.children)
546
+ }
547
+
548
+ const slots = customElementSlots[id]
549
+
550
+ if (slots) {
551
+ const componentSlots = component.slotElements[id]
552
+ const usedSlots = {}
553
+ const slotIndexes = {}
554
+
555
+ for (let i = 0; i < slots.length; i++) {
556
+ const slot = slots[i]
557
+ const name = slot.name
558
+
559
+ if (componentSlots[name]) {
560
+ const componentSlot = componentSlots[name]
561
+ const parentChildren = componentSlot.element.parent.children
562
+ let slotIndex = slotIndexes[name]
563
+ let deleteCount = 0
564
+
565
+ if (slotIndex == null) {
566
+ slotIndex = parentChildren.indexOf(componentSlot.element, componentSlot.element.parentChildIndex)
567
+ deleteCount = 1
568
+ slotIndexes[name] = slotIndex
569
+ }
570
+
571
+ parentChildren.splice(slotIndex, deleteCount, slot.element)
572
+ usedSlots[slot.name] = true
573
+ }
574
+ }
575
+
576
+ for (const name in componentSlots) {
577
+ if (Object.prototype.hasOwnProperty.call(componentSlots, name)) {
578
+ if (!usedSlots[name]) {
579
+ const componentSlot = componentSlots[name]
580
+ const element = componentSlot.element
581
+ const parent = element.parent
582
+ const children = element.children || [{
583
+ type: 'text',
584
+ data: ''
585
+ }]
586
+ const slotIndex = parent.children.indexOf(componentSlot.element, componentSlot.element.parentChildIndex)
587
+
588
+
589
+ for (let i = 0; i < children.length; i++) {
590
+ const childNode = children[i]
591
+ childNode.parent = parent
592
+ }
593
+
594
+ // replace unused slots with default values
595
+ parent.children.splice(slotIndex, 1, ...children)
596
+ }
597
+ }
598
+ }
599
+ }
600
+
601
+ return template
602
+ }
603
+
604
+ /**
605
+ * Parses a Coralite module script and compiles it into JavaScript.
606
+ *
607
+ * @param {CoraliteModule} component - The Coralite module to parse
608
+ * @param {Object.<string, string>} values - Replacement tokens for the component
609
+ * @param {Object.<string, CoraliteModule>} components - Mapping of other components that might be referenced
610
+ * @param {CoraliteDocument} document - The current document being processed
611
+ * @returns {Promise<Object.<string,(string|(CoraliteElement|CoraliteTextNode)[])>>}
612
+ *
613
+ * @example
614
+ * ```javascript
615
+ * // Example usage:
616
+ * const parsedResult = await parseScript({
617
+ * id: 'my-component',
618
+ * template: `<div>Hello World</div>`,
619
+ * script: `export default function () {
620
+ * return '<div>Hello World</div>';
621
+ * }`,
622
+ * tokens: { 'hello': 'Hello' }
623
+ * }, {
624
+ * 'my-component': {
625
+ * id: 'my-component',
626
+ * template: `<div>${tokens.hello}</div>`,
627
+ * script: `export default function () {
628
+ * return '<div>Hello</div>';
629
+ * }`
630
+ * }
631
+ * }, document);
632
+ * ```
633
+ */
634
+ export async function parseScript (component, values, components, document) {
635
+ const contextifiedObject = vm.createContext({
636
+ coralite: {
637
+ tokens: values,
638
+ /**
639
+ * @param {Object} options
640
+ * @param {Object.<string, (string | function)>} options.tokens
641
+ * @returns {Promise<Object.<string, string>>}
642
+ */
643
+ async defineComponent (options) {
644
+ /** @type {Object.<string, string>} */
645
+ const values = {}
646
+
647
+ if (options.tokens) {
648
+ for (const key in options.tokens) {
649
+ if (Object.prototype.hasOwnProperty.call(options.tokens, key)) {
650
+ const token = options.tokens[key]
651
+
652
+ if (typeof token === 'function') {
653
+ values[key] = await token()
654
+ }
655
+ }
656
+ }
657
+ }
658
+
659
+ return values
660
+ },
661
+ /**
662
+ * @param {Object} options
663
+ * @param {string} options.componentId
664
+ * @param {string} options.path
665
+ */
666
+ async aggregate (options) {
667
+ const component = components[options.componentId]
668
+
669
+ if (!component) {
670
+ throw new Error('Aggregate: no component found by the id: ' + options.componentId)
671
+ }
672
+
673
+ return await aggregate(options, values, components, document)
674
+ }
675
+ }
676
+ })
677
+
678
+ const script = new vm.SourceTextModule(component.script, {
679
+ context: contextifiedObject
680
+ })
681
+
682
+ await script.link(moduleLinker)
683
+ await script.evaluate()
684
+
685
+ // @ts-ignore
686
+ if (script.namespace.default) {
687
+ // @ts-ignore
688
+ return await script.namespace.default
689
+ }
690
+
691
+ throw new Error('Script found module export: "' + component.id + '"')
692
+ }
693
+
694
+ /**
695
+ * @param {string} specifier - The specifier of the requested module
696
+ * @param {Module} referencingModule - The Module object link() is called on.
697
+ */
698
+ async function moduleLinker (specifier, referencingModule) {
699
+ if (specifier === 'coralite') {
700
+ return new vm.SourceTextModule(`
701
+ export const tokens = coralite.tokens
702
+ export const defineComponent = coralite.defineComponent
703
+ export const aggregate = coralite.aggregate
704
+ export default { tokens, defineComponent, aggregate };
705
+ `, { context: referencingModule.context })
706
+ }
707
+
708
+ throw new Error(`Unable to resolve dependency: ${specifier}`)
709
+ }
710
+
711
+ /**
712
+ * Extract attributes from string
713
+ * @param {string} string
714
+ * @returns {CoraliteToken[]}
715
+ */
716
+ function getTokensFromString (string) {
717
+ const matches = string.matchAll(/\{{[^}]*\}}/g)
718
+ const result = []
719
+
720
+ for (const match of matches) {
721
+ const token = match[0]
722
+
723
+ result.push({
724
+ name: token.slice(2, token.length -2).trim(),
725
+ content: token
726
+ })
727
+ }
728
+
729
+ return result
730
+ }
731
+
732
+ /**
733
+ * In place add metadata to meta
734
+ * @param {Object.<string, CoraliteToken[]>} meta
735
+ * @param {string} name
736
+ * @param {string} content
737
+ */
738
+ function addMetadata (meta, name, content) {
739
+ let entry = meta[name]
740
+
741
+ if (!entry) {
742
+ meta[name] = []
743
+ entry = meta[name]
744
+ }
745
+
746
+ // add og graph
747
+ entry.push({
748
+ name,
749
+ content
750
+ })
751
+ }
752
+
753
+ /**
754
+ * @param {string} name
755
+ * @param {Object.<string, string>} attributes
756
+ * @param {CoraliteElement[]} customElements
757
+ * @param {CoraliteElement[]} stack
758
+ * @param {(CoraliteTextNode | CoraliteElement | CoraliteDirective)[]} root
759
+ */
760
+ function createElement (name, attributes, customElements, stack, root) {
761
+ const sanitisedName = name.toLowerCase()
762
+ const parentIndex = stack.length - 1
763
+ /** @type {CoraliteElement} */
764
+ const element = {
765
+ type: 'tag',
766
+ name: sanitisedName,
767
+ attribs: attributes,
768
+ children: []
769
+ }
770
+
771
+ if (!validTags[sanitisedName]) {
772
+ if (invalidCustomTags[sanitisedName]) {
773
+ throw new Error('Element name is reserved: "'+ sanitisedName +'"')
774
+ }
775
+
776
+ if (customElementTagRegExp.test(sanitisedName)) {
777
+ // store custom elements
778
+ customElements.push(element)
779
+ } else {
780
+ throw new Error('Invalid custom element tag name: "' + sanitisedName + '" https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name')
781
+ }
782
+ }
783
+
784
+ if (parentIndex === -1) {
785
+ root.push(element)
786
+ } else {
787
+ const parent = stack[parentIndex]
788
+
789
+ element.parent = parent
790
+ element.parentChildIndex = parent.children.length
791
+
792
+ // add element to its parent's children
793
+ parent.children.push(element)
794
+ }
795
+
796
+ // push element to stack as it may have children
797
+ stack.push(element)
798
+
799
+ return element
800
+ }
801
+
802
+ /**
803
+ * @param {string} text
804
+ * @param {CoraliteElement[]} stack
805
+ * @returns {CoraliteTextNode}
806
+ */
807
+ function createTextNode (text, stack) {
808
+ // store if contains data
809
+ const parentIndex = stack.length - 1
810
+ const textNode = {
811
+ type: 'text',
812
+ data: text,
813
+ parent: stack[parentIndex]
814
+ }
815
+
816
+ stack[parentIndex].children.push(textNode)
817
+
818
+ return textNode
819
+ }
820
+