Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 15 additions & 248 deletions src/abode.ts
Original file line number Diff line number Diff line change
@@ -1,251 +1,6 @@
import { getCurrentScript } from 'tiny-current-script';
import { createElement, FC } from 'react';
import { createRoot, Root } from 'react-dom/client';

interface RegisteredComponents {
[key: string]: {
module: Promise<any>;
options?: { propParsers?: PropParsers };
};
}

interface Props {
[key: string]: string;
}

interface Options {
propParsers?: PropParsers;
}
interface PropParsers {
[key: string]: ParseFN;
}

interface HTMLElementAttributes {
[key: string]: string;
}

interface PopulateOptions {
attributes?: HTMLElementAttributes;
callback?: () => void;
}

export type RegisterPromise = () => Promise<any>;
export type RegisterComponent = () => FC<any>;
export type RegisterFN = RegisterPromise | RegisterComponent;
export type ParseFN = (rawProp: string) => any;

export let componentSelector = 'data-component';
export let components: RegisteredComponents = {};
export let unPopulatedElements: Element[] = [];

export const register = (name: string, fn: RegisterFN, options?: Options) => {
components[name] = { module: retry(fn, 10, 20), options };
};

export const unRegisterAllComponents = () => {
components = {};
};

export const delay = (ms: number) => new Promise(res => setTimeout(res, ms));

const retry = async (
fn: () => any,
times: number,
delayTime: number
): Promise<any> => {
try {
return await fn();
} catch (err) {
if (times > 1) {
await delay(delayTime);
return retry(fn, times - 1, delayTime * 2);
} else {
throw new Error(err as string);
}
}
};

export const setComponentSelector = (selector: string) => {
componentSelector = selector;
};

export const getRegisteredComponents = () => {
return components;
};

export const getActiveComponents = () => {
return Array.from(
new Set(getAbodeElements().map(el => el.getAttribute(componentSelector)))
);
};

// start prop logic
export const getCleanPropName = (raw: string): string => {
return raw.replace('data-prop-', '').replace(/-./g, x => x.toUpperCase()[1]);
};

export const getElementProps = (
el: Element | HTMLScriptElement,
options?: Options
): Props => {
const props: { [key: string]: string } = {};

if (el?.attributes) {
const rawProps = Array.from(el.attributes).filter(attribute =>
attribute.name.startsWith('data-prop-')
);
rawProps.forEach(prop => {
const componentName = getComponentName(el) ?? '';
const propName = getCleanPropName(prop.name);
const propParser =
options?.propParsers?.[propName] ??
components[componentName]?.options?.propParsers?.[propName];
if (propParser) {
// custom parse function for prop
props[propName] = propParser(prop.value);
} else {
// default json parsing
if (/^0+\d+$/.test(prop.value)) {
/*
ie11 bug fix;
in ie11 JSON.parse will parse a string with leading zeros followed
by digits, e.g. '00012' will become 12, whereas in other browsers
an exception will be thrown by JSON.parse
*/
props[propName] = prop.value;
} else {
try {
props[propName] = JSON.parse(prop.value);
} catch (e) {
props[propName] = prop.value;
}
}
}
});
}

return props;
};

export const getScriptProps = (options?: Options) => {
const element = getCurrentScript();
if (element === null) {
throw new Error('Failed to get current script');
}
return getElementProps(element, options);
};
// end prop logic

// start element logic
export const getAbodeElements = (): Element[] => {
return Array.from(document.querySelectorAll(`[${componentSelector}]`)).filter(
el => {
const component = el.getAttribute(componentSelector);

// It should exist in registered components
return component && components[component];
}
);
};

export const setUnpopulatedElements = () => {
unPopulatedElements = getAbodeElements().filter(
el => !el.getAttribute('react-abode-populated')
);
};

export const setAttributes = (
el: Element,
attributes: HTMLElementAttributes
) => {
Object.entries(attributes).forEach(([k, v]) => el.setAttribute(k, v));
};

// end element logic

function getComponentName(el: Element) {
return Array.from(el.attributes).find(at => at.name === componentSelector)
?.value;
}

export const renderAbode = async (el: Element, root: Root) => {
const props = getElementProps(el);

const componentName = getComponentName(el);

if (!componentName || componentName === '') {
throw new Error(
`not all react-abode elements have a value for ${componentSelector}`
);
}

const module = await components[componentName]?.module;
if (!module) {
throw new Error(`no component registered for ${componentName}`);
}

const element = module.default || module;

root.render(createElement(element, props));
};

export const trackPropChanges = (el: Element, root: Root) => {
if (MutationObserver) {
const observer = new MutationObserver(() => {
renderAbode(el, root);
});
observer.observe(el, { attributes: true });
}
};

function unmountOnNodeRemoval(element: any, root: Root) {
const observer = new MutationObserver(function() {
function isDetached(el: any): any {
if (el.parentNode === document) {
return false;
} else if (el.parentNode === null) {
return true;
} else {
return isDetached(el.parentNode);
}
}

if (isDetached(element)) {
observer.disconnect();
root.unmount();
}
});

observer.observe(document, {
childList: true,
subtree: true,
});
}

export const update = async (
elements: Element[],
options?: PopulateOptions
) => {
// tag first, since adding components is a slow process and will cause components to get iterated multiple times
elements.forEach(el => el.setAttribute('react-abode-populated', 'true'));
elements.forEach(el => {
const root = createRoot(el);
if (options?.attributes) setAttributes(el, options.attributes);
renderAbode(el, root);
trackPropChanges(el, root);
unmountOnNodeRemoval(el, root);
});
};

const checkForAndHandleNewComponents = async (options?: PopulateOptions) => {
setUnpopulatedElements();

if (unPopulatedElements.length) {
await update(unPopulatedElements, options);
unPopulatedElements = [];
if (options?.callback) options.callback();
}
};
import type { Options, PopulateOptions, RegisterFN } from 'types';
import { components } from './constants';
import { checkForAndHandleNewComponents, retry } from './helpers';

export const populate = async (options?: PopulateOptions) => {
await checkForAndHandleNewComponents(options);
Expand All @@ -254,3 +9,15 @@ export const populate = async (options?: PopulateOptions) => {
checkForAndHandleNewComponents(options)
);
};

/**
* Register adds a component to the list of available components
*
* The registered components will be used to populate a given DOM.
* @param name Name of the component
* @param fn The React component function
* @param options Options object
*/
export const register = (name: string, fn: RegisterFN, options?: Options) => {
components.set(name, { module: retry(fn, 10, 20), options });
};
13 changes: 13 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { RegisteredComponents } from 'types';

export let componentSelector = 'data-component';
export let components: RegisteredComponents = new Map();
export let unpopulatedElements: Element[] = [];

export const setComponentSelector = (selector: string) => {
componentSelector = selector;
};

export const setUnpopulatedElements = (value: Element[]) => {
unpopulatedElements = value;
};
Loading