@sitecore-feaas/clientside 0.0.1

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/src/dom.ts ADDED
@@ -0,0 +1,321 @@
1
+ import { JSONPath } from 'jsonpath-plus'
2
+ import { CloudStylesheet, CloudTemplate, fetchComponent, fetchStylesheet } from './cloud.js'
3
+
4
+ export interface FEAASCustomizations {
5
+ processTemplate?: (element: HTMLElement, template: string, data: any) => string
6
+ processData?: (element: HTMLElement, data: any) => any
7
+ processValue?: (element: HTMLElement, property: string, value: any) => any
8
+ shouldRepeat?: (element: HTMLElement, value: any) => boolean
9
+ onRender?: (element: HTMLElement, data: any) => void
10
+ }
11
+
12
+ export interface ExplicitTemplate {
13
+ template: string
14
+ }
15
+
16
+ export type Datascopes = Record<string, any>
17
+
18
+ export type FEAASComponentProps = {
19
+ data?: Datascopes
20
+ } & (ExplicitTemplate | CloudTemplate) &
21
+ FEAASCustomizations
22
+
23
+ export function renderComponentElement(content: string, datasources?: Datascopes) {
24
+ const wrapper = document.createElement('div')
25
+ wrapper.innerHTML = content
26
+ const fragment = document.createDocumentFragment()
27
+ while (wrapper.firstChild) {
28
+ fragment.appendChild(wrapper.firstChild)
29
+ }
30
+ renderDOMElement(fragment, datasources)
31
+ return wrapper
32
+ }
33
+
34
+ export function cleanCollectionBit(path: string) {
35
+ return path.replace(/(\[[^\]\[]+\]|\.\*)$/g, '')
36
+ }
37
+
38
+ export function normalizeCollectionScope(path: string) {
39
+ return cleanCollectionBit(path) + '.*'
40
+ }
41
+
42
+ function getDOMCollectionIndex(element: HTMLElement) {
43
+ const datascope = element.getAttribute('data-path-scope')
44
+ var index = 0
45
+ for (var p = element; (p = <HTMLElement>p.previousElementSibling); ) {
46
+ if (p.getAttribute('data-path-scope') == datascope) {
47
+ index++
48
+ }
49
+ }
50
+ return index
51
+ }
52
+
53
+ function updateDOMClones(element: HTMLElement, targetCount: number, config?: FEAASCustomizations) {
54
+ const datascope = element.getAttribute('data-path-scope')
55
+ if (datascope == null) {
56
+ return
57
+ }
58
+ var index = getDOMCollectionIndex(element)
59
+ if (index != 0) {
60
+ return
61
+ }
62
+
63
+ const parentElement = element.parentElement
64
+ if (parentElement == null) {
65
+ return
66
+ }
67
+
68
+ /*dont remove first element, just hide it*/
69
+ if (targetCount == 0) {
70
+ element.style.display = 'none'
71
+ } else if (element.style.display) {
72
+ element.style.display = ''
73
+ if (element.getAttribute('style') == '') element.removeAttribute('style')
74
+ }
75
+
76
+ var count = index + 1
77
+ var last = element
78
+ for (var n = element; (n = <HTMLElement>n.nextElementSibling); ) {
79
+ if (n.getAttribute('data-path-scope') == datascope) {
80
+ count++
81
+ last = n
82
+ }
83
+ }
84
+ /* add missing clones */
85
+ for (var i = count; i < targetCount; i++) {
86
+ parentElement.insertBefore(last.cloneNode(true), last.nextElementSibling)
87
+ }
88
+ /* remove excessive clones fron previous renders */
89
+ for (var i = Math.max(1, targetCount); i < count; i++) {
90
+ last = <HTMLElement>last.previousElementSibling
91
+ parentElement.removeChild(<HTMLElement>last.nextElementSibling)
92
+ }
93
+ }
94
+
95
+ export function queryScopes(scopes: any, datapath: string, singleValue = false, json: any = scopes) {
96
+ var path = datapath
97
+ /* find closest parent scope */
98
+ const keys = Object.keys(scopes).sort((b, a) => a.length - b.length)
99
+ for (const scope of keys) {
100
+ if (datapath.startsWith(scope) && 'ends with it') {
101
+ if (scope == datapath) {
102
+ if (singleValue) {
103
+ return scopes[scope]
104
+ } else {
105
+ continue
106
+ }
107
+ }
108
+ path = datapath.substring(scope.length + 1)
109
+ json = scopes[scope]
110
+ break
111
+ }
112
+ }
113
+
114
+ const collection = JSONPath({
115
+ path: path,
116
+ json: json
117
+ })
118
+ if (singleValue && collection) {
119
+ return collection[0]
120
+ } else {
121
+ return collection
122
+ }
123
+ }
124
+
125
+ export function setDOMAttribute(element: HTMLElement, attribute: string, value: any, config?: FEAASCustomizations) {
126
+ if (!element) return
127
+ var target = element
128
+ if (config?.processValue) {
129
+ value = config?.processValue(element, attribute, value)
130
+ }
131
+ var prop = attribute.replace('data-path-', '')
132
+ if (attribute == 'data-path-src') {
133
+ target = element.tagName == 'IMG' ? element : <HTMLElement>element.querySelector('img, video')
134
+ } else if (attribute == 'data-path-href') {
135
+ target = element.querySelector('a') || element.closest('a')
136
+ } else if (attribute == 'data-path-hidden') {
137
+ value = !value ? true : null
138
+ } else if (attribute == 'data-path' || attribute == 'data-embed-html' || attribute == 'html') {
139
+ prop = 'innerHTML'
140
+ } else if (attribute == 'data-embed-src') {
141
+ loadScript(element, value)
142
+ } else if (attribute == 'data-path-attributes') {
143
+ const oldKeys = element.getAttribute('data-attributes-keys')?.split(',').filter(Boolean) || []
144
+ for (var property in value) {
145
+ var subvalue = value[property]
146
+ setDOMAttribute(
147
+ element,
148
+ property,
149
+ typeof subvalue == 'object' && subvalue ? JSON.stringify(subvalue) : subvalue,
150
+ config
151
+ )
152
+ }
153
+ for (var i = 0; i < oldKeys.length; i++) {
154
+ if (value == null || !(oldKeys[i] in value)) {
155
+ setDOMAttribute(element, oldKeys[i], null, config)
156
+ }
157
+ }
158
+ setDOMAttribute(element, 'data-attributes-keys', Object.keys(value || {}).join(','))
159
+ return
160
+ }
161
+ if (typeof value == 'boolean') {
162
+ value = value ? '' : null
163
+ }
164
+ if (!target) return
165
+
166
+ var placeholder = String(element.getAttribute('data-path-placeholder') || target[prop as keyof HTMLElement])
167
+
168
+ if (value != null) {
169
+ if (prop == 'innerHTML') {
170
+ if (target.innerHTML != value) {
171
+ target.innerHTML = value
172
+ }
173
+ } else {
174
+ if (target.getAttribute(prop) != value) {
175
+ target.setAttribute(prop, value)
176
+ }
177
+ }
178
+ } else {
179
+ if (prop == 'innerHTML') {
180
+ target.innerHTML = placeholder
181
+ } else {
182
+ target.removeAttribute(prop)
183
+ }
184
+ }
185
+ }
186
+
187
+ export function observeDOMElement(element: HTMLElement) {
188
+ const observer = new element.ownerDocument.defaultView.MutationObserver((changes) => {
189
+ changes.forEach((change) => {
190
+ const target = change.target as HTMLElement
191
+ const src = target.getAttribute('data-embed-src')
192
+ if (src) {
193
+ loadScript(target, src)
194
+ }
195
+
196
+ if (change.addedNodes) {
197
+ change.addedNodes.forEach((node) => {
198
+ if (node.nodeType == 1) autoloadScripts(node as HTMLElement, false)
199
+ })
200
+ }
201
+ })
202
+ })
203
+ observer.observe(element, { attributes: true, subtree: true, childList: true })
204
+ return observer
205
+ }
206
+
207
+ export function autoloadScripts(element: HTMLElement, observe = true) {
208
+ const embeds = element.querySelectorAll('[data-embed-src]')
209
+ for (var i = 0; i < embeds.length; i++) {
210
+ const embed = embeds[i]
211
+ loadScript(embed as HTMLElement, embed.getAttribute('data-embed-src'))
212
+ }
213
+ if (observe) return observeDOMElement(element)
214
+ }
215
+
216
+ export function loadScript(element: HTMLElement, src: string) {
217
+ for (var p = element; p; p = p.parentElement) {
218
+ if (!p.parentElement) {
219
+ const scripts = p.querySelectorAll('script')
220
+ for (var i = 0; i < scripts.length; i++) {
221
+ if (scripts[i].getAttribute('src') == src) {
222
+ return true
223
+ }
224
+ }
225
+ if (p.tagName == 'HTML') p = p.querySelector('head') || p
226
+ const script = p.ownerDocument.createElement('script')
227
+ script.type = 'module'
228
+ script.src = src
229
+ p.appendChild(script)
230
+ break
231
+ }
232
+ }
233
+ }
234
+
235
+ // order in which attributes are proccesed. Rightmost are more important
236
+ var sortOrder = ['data-attributes-keys', 'data-path-attributes', 'data-path-scope']
237
+ export function renderDOMElement(
238
+ input: HTMLElement | DocumentFragment,
239
+ datascopes?: any,
240
+ config?: FEAASCustomizations,
241
+ isNested?: boolean
242
+ ) {
243
+ const element = <HTMLElement>input
244
+ if (!isNested) {
245
+ datascopes = config?.processData?.(element, datascopes) || datascopes
246
+ }
247
+ if (element.nodeType == 1) {
248
+ const datascope = element.getAttribute('data-path-scope')
249
+ if (datascope) {
250
+ const index = getDOMCollectionIndex(element)
251
+ const collection = queryScopes(datascopes, datascope, false) || []
252
+ if (index == 0) {
253
+ updateDOMClones(element, config?.shouldRepeat?.(element, collection) === false ? 1 : collection.length, config)
254
+ }
255
+
256
+ datascopes = {
257
+ ...datascopes,
258
+ [normalizeCollectionScope(datascope)]: collection[index]
259
+ }
260
+ }
261
+ const attrs = Array.prototype.slice.call(element.attributes).sort((a, b) => {
262
+ return sortOrder.indexOf(a.name) - sortOrder.indexOf(b.name)
263
+ })
264
+ for (var i = 0; i < attrs.length; i++) {
265
+ const { name, value: datapath } = attrs[i]
266
+ if (name.startsWith('data-path') && name != 'data-path-scope') {
267
+ setDOMAttribute(element, name, queryScopes(datascopes, datapath, true), config)
268
+ }
269
+ if (name == 'data-embed-src') {
270
+ setDOMAttribute(element, name, element.getAttribute(name), config)
271
+ }
272
+ }
273
+ }
274
+ for (var i = 0; i < element.children.length; i++) {
275
+ renderDOMElement(<HTMLElement>element.children[i], datascopes, config, true)
276
+ }
277
+ if (!isNested) {
278
+ config?.onRender?.(element, datascopes)
279
+ }
280
+ return element
281
+ }
282
+
283
+ export function renderDOMContent(root: HTMLElement, template: string, data?: Datascopes, config?: FEAASCustomizations) {
284
+ const newTemplate = config?.processTemplate?.(root, template, data) || template
285
+ root.innerHTML = newTemplate
286
+ root.classList.add('-feaas')
287
+ return renderDOMElement(root, data, config)
288
+ }
289
+
290
+ export function renderStylesheet(props: CloudStylesheet, style?: HTMLStyleElement) {
291
+ renderStylesheetPromise(props, (style ||= document.createElement('style')))
292
+ return style
293
+ }
294
+
295
+ export function renderStylesheetPromise(props: CloudStylesheet, style?: HTMLStyleElement) {
296
+ style ||= document.createElement('style')
297
+ return fetchStylesheet(props, (cssText) => {
298
+ style.textContent = cssText
299
+ }).then(() => style)
300
+ }
301
+
302
+ export function renderComponent(props: FEAASComponentProps, root?: HTMLElement) {
303
+ renderComponentPromise(props, (root ||= document.createElement('div')))
304
+ return root
305
+ }
306
+
307
+ export async function renderComponentPromise(props: FEAASComponentProps, root?: HTMLElement) {
308
+ root ||= document.createElement('div')
309
+ if (!root.childNodes.length) {
310
+ if ('template' in props) {
311
+ return renderDOMContent(root, props.template, props.data, props)
312
+ } else {
313
+ return fetchComponent(props, (template) => {
314
+ return renderDOMContent(root, template, props.data, props)
315
+ }).then(() => root)
316
+ }
317
+ /* update element with new data */
318
+ } else {
319
+ return renderDOMElement(root, props.data, props)
320
+ }
321
+ }
package/src/fetch.ts ADDED
@@ -0,0 +1,64 @@
1
+ /* Utility to cache-bust static files.
2
+
3
+ It handles multiple situations:
4
+
5
+ 1. Initial request, valid response
6
+ 2. Initial request, invalid response
7
+ 2. Initial request, missing response
8
+ 3. Cached request, valid response (1 extra head request)
9
+ 4. Cached request, invalid response, valid refetch (2 fetches + 1 head)
10
+ 5. Cached request, invalid response, invalid refetch (2 fetches + 1 head)
11
+
12
+ It works as promise, but also accepts callback to use cached blobs mid-flight and have access to headers.
13
+ Any network errors will not caught.
14
+ Function throws when unable to fetch valid response.
15
+
16
+ */
17
+ export async function fetchAndRevalidate(
18
+ url: Parameters<typeof fetch>[0],
19
+ options: Parameters<typeof fetch>[1],
20
+ validator: (resposne: Response) => boolean,
21
+ callback: (response: Response, status: 'cached' | 'valid') => void
22
+ ) {
23
+ // make request as usual, see if there's cached version of an asset
24
+ const date = new Date()
25
+ const initialResponse = await fetch(url, options)
26
+ if (!initialResponse.ok) throw new Error('Fetch failed')
27
+ const responseDate = new Date(initialResponse.headers.get('date'))
28
+ var response = initialResponse
29
+ callback(initialResponse, 'cached')
30
+
31
+ // see if response was pulled from cache
32
+ if (responseDate < date) {
33
+ // check if response was stale by sending quick HEAD reque st busting cache
34
+ const freshHeadResponse = await fetch(url + '?' + Math.random(), {
35
+ ...options,
36
+ method: 'HEAD',
37
+ cache: 'no-cache'
38
+ })
39
+
40
+ // there's a newer version of the file that we may fetch
41
+ if (freshHeadResponse.headers.get('last-modified') != initialResponse.headers.get('last-modified')) {
42
+ // check if re-fetching is even necessary
43
+ if (validator(freshHeadResponse) === false) {
44
+ //callback('stale', freshHeadResponse)
45
+ throw new Error('Response is stale')
46
+ }
47
+
48
+ // otherwise the new resource has to be re-fetched
49
+ response = await fetch(url, {
50
+ ...options,
51
+ cache: 'reload'
52
+ })
53
+ }
54
+ }
55
+
56
+ if (validator(response) === false) {
57
+ throw new Error('Response is not valid')
58
+ }
59
+ if (initialResponse !== response) {
60
+ callback(response, 'valid')
61
+ }
62
+
63
+ return response
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export * from './dom.js'
2
+ export * from './webcomponent.js'
3
+ export * from './fetch.js'
4
+
5
+ import * as DOM from './dom.js'
6
+ import * as WEB from './webcomponent.js'
7
+
8
+ export default {
9
+ ...DOM,
10
+ ...WEB
11
+ }
package/src/react.ts ADDED
@@ -0,0 +1,66 @@
1
+ import React from 'react'
2
+ import { CloudStylesheet, fetchComponent, fetchStylesheet } from './cloud.js'
3
+ import { FEAASComponentProps, renderDOMElement } from './dom.js'
4
+ export * from './dom.js'
5
+ export * from './cloud.js'
6
+ export * from './webcomponent.js'
7
+ import * as DOM from './dom.js'
8
+ import * as WEB from './webcomponent.js'
9
+
10
+ export function Component(props: FEAASComponentProps) {
11
+ const [template, setTemplate] = React.useState('template' in props ? props.template : null)
12
+ const data = props.data
13
+ const ref = React.useRef<HTMLElement>(null)
14
+
15
+ React.useEffect(
16
+ () => {
17
+ if (template == null && !('template' in props)) {
18
+ fetchComponent(props, setTemplate).catch(() => {
19
+ console.error(
20
+ `FEAAS: Could not fetch component: ${props.library}/${props.component}/${props.variant}/${props.version}`
21
+ )
22
+ })
23
+ }
24
+ },
25
+ 'template' in props
26
+ ? [props.template, null, null, null]
27
+ : [props.library, props.component, props.variant, props.version, props.hostname]
28
+ )
29
+
30
+ React.useEffect(() => {
31
+ if (ref.current && template) {
32
+ ref.current.innerHTML = template
33
+ }
34
+ }, [ref, template])
35
+
36
+ React.useEffect(() => {
37
+ if (template && ref.current) {
38
+ renderDOMElement(ref.current, data || {}, props)
39
+ }
40
+ }, [ref, template, data])
41
+
42
+ return React.createElement('div', { ref, className: '-feaas' })
43
+ }
44
+
45
+ export function Stylesheet(props: CloudStylesheet) {
46
+ const ref = React.useRef<HTMLStyleElement>(null)
47
+ const [css, setCss] = React.useState<string>('')
48
+
49
+ React.useEffect(() => {
50
+ fetchStylesheet(props, setCss).catch((e) => {
51
+ console.error(`FEAAS: Could not fetch stylesheet for library ${props.library}`)
52
+ })
53
+ }, [props.hostname, props.library])
54
+
55
+ return React.createElement(
56
+ 'style',
57
+ { ref, rel: 'stylesheet', className: '-stylesheet', 'data-library-id': props.library },
58
+ [css]
59
+ )
60
+ }
61
+ export default {
62
+ ...DOM,
63
+ ...WEB,
64
+ Stylesheet,
65
+ Component
66
+ }
@@ -0,0 +1,169 @@
1
+ import { CloudTemplate, fetchComponent, fetchStylesheet } from './cloud.js'
2
+ import { Datascopes, renderDOMContent, renderDOMElement } from './dom.js'
3
+
4
+ declare global {
5
+ namespace JSX {
6
+ interface IntrinsicElements {
7
+ 'feaas-component': {
8
+ data?: any
9
+ library: string
10
+ component: string
11
+ variant: string
12
+ version?: string
13
+ hostname?: string
14
+ template?: string
15
+ children?: any
16
+ }
17
+ 'feaas-stylesheet': {
18
+ library?: string
19
+ hostname?: string
20
+ children?: any
21
+ }
22
+ }
23
+ }
24
+ }
25
+
26
+ // Shim in case it's required in node.js environment
27
+ var WebComponent = typeof HTMLElement == 'undefined' ? (class {} as unknown as typeof HTMLElement) : HTMLElement
28
+
29
+ export class HTMLFeaasComponent extends WebComponent {
30
+ static get observedAttributes() {
31
+ return ['data', 'library', 'component', 'variant', 'version', 'hostname', 'template']
32
+ }
33
+ connected = false
34
+
35
+ template: string = null
36
+ data: any = undefined
37
+ isFetching: boolean = false
38
+
39
+ connectedCallback() {
40
+ this.connected = true
41
+ this.render()
42
+ }
43
+
44
+ disconnectedCallback() {}
45
+
46
+ attributeChangedCallback(name: string, oldValue: string, newValue: string) {
47
+ if (!this.connected) return
48
+ if (name == 'template') {
49
+ this.template = newValue
50
+ }
51
+ if (name == 'data' && this.children.length > 0) {
52
+ this.update()
53
+ } else {
54
+ this.render()
55
+ }
56
+ }
57
+
58
+ update() {
59
+ renderDOMElement(this, this.getProps().data)
60
+ }
61
+
62
+ setData(data: any) {
63
+ this.data = data
64
+ if (this.children.length > 0) {
65
+ this.update()
66
+ } else {
67
+ this.render()
68
+ }
69
+ }
70
+
71
+ getOriginal() {
72
+ const scope = this.getAttribute('data-path-scope')
73
+ if (!scope) return
74
+ for (var current = this as Element; (current = current.previousElementSibling); ) {
75
+ if (current.getAttribute('data-path-scope') == scope) {
76
+ var last = current
77
+ } else {
78
+ break
79
+ }
80
+ }
81
+ return last as HTMLFeaasComponent
82
+ }
83
+
84
+ getData(): any {
85
+ if (this.data) {
86
+ return this.data
87
+ }
88
+ if (this.getAttribute('data')) {
89
+ const dataValue = this.getAttribute('data')
90
+ return typeof dataValue == 'string' ? JSON.parse(dataValue) : {}
91
+ }
92
+ return this.getOriginal()?.getData() || {}
93
+ return {}
94
+ }
95
+
96
+ render() {
97
+ // defer rendering to original element
98
+ if (this.getOriginal()) {
99
+ return
100
+ }
101
+ const newProps = this.getProps()
102
+ if (this.template == null) {
103
+ if (!this.isFetching) {
104
+ if (newProps.component != null && newProps.variant != null) {
105
+ this.isFetching = true
106
+ fetchComponent(newProps, (template) => {
107
+ this.template = template
108
+ this.isFetching = false
109
+ renderDOMContent(this, this.template, newProps.data)
110
+ }).catch(() => {
111
+ console.error(
112
+ `FEAAS: Could not fetch component: ${newProps.component}/${newProps.variant}/${newProps.version}`
113
+ )
114
+ })
115
+ }
116
+ }
117
+ } else {
118
+ renderDOMContent(this, this.template, newProps.data)
119
+ }
120
+ }
121
+
122
+ getProps(): CloudTemplate & { data: Datascopes } {
123
+ return {
124
+ data: this.getData(),
125
+ library: (this.getAttribute('library') || undefined) as string,
126
+ component: (this.getAttribute('component') || undefined) as string,
127
+ variant: (this.getAttribute('variant') || undefined) as string,
128
+ version: (this.getAttribute('version') || undefined) as string,
129
+ hostname: (this.getAttribute('hostname') || undefined) as string
130
+ }
131
+ }
132
+ }
133
+
134
+ export class HTMLFeaasStylesheet extends WebComponent {
135
+ static get observedAttributes() {
136
+ return ['library', 'version', 'hostname']
137
+ }
138
+ connected = false
139
+ connectedCallback() {
140
+ this.connected = true
141
+ this.render()
142
+ }
143
+
144
+ attributeChangedCallback() {
145
+ if (this.connected) this.render()
146
+ }
147
+
148
+ render() {
149
+ const props = this.getProps()
150
+ fetchStylesheet(props, (cssText) => {
151
+ this.innerHTML = '<style>' + cssText + '</style>'
152
+ }).catch((e) => {
153
+ console.error(`FEAAS: Could not fetch stylesheet for library ${props.library}`)
154
+ })
155
+ }
156
+
157
+ getProps() {
158
+ return {
159
+ library: (this.getAttribute('library') || undefined) as string,
160
+ version: (this.getAttribute('version') || undefined) as string,
161
+ hostname: (this.getAttribute('hostname') || undefined) as string
162
+ }
163
+ }
164
+ }
165
+
166
+ if (typeof window != 'undefined' && !window.customElements.get('feaas-stylesheet')) {
167
+ window.customElements.define('feaas-stylesheet', HTMLFeaasStylesheet)
168
+ window.customElements.define('feaas-component', HTMLFeaasComponent)
169
+ }