diff --git a/blocks/breadcrumbs/breadcrumbs.js b/blocks/breadcrumbs/breadcrumbs.js
index 09998870..d35d88e5 100644
--- a/blocks/breadcrumbs/breadcrumbs.js
+++ b/blocks/breadcrumbs/breadcrumbs.js
@@ -26,7 +26,8 @@ export default class Breadcrumbs extends ComponentBase {
connected() {
this.classList.add('full-width');
this.classList.add('breadcrumbs');
- this.path = window.location.href.split(getBaseUrl()).join('/').split('/');
+ const { origin, pathname } = window.location;
+ this.path = `${origin}${pathname}`.split(getBaseUrl()).join('/').split('/');
this.innerHTML = `
${this.path
diff --git a/blocks/header/header.js b/blocks/header/header.js
index 288e4149..c1ef9540 100644
--- a/blocks/header/header.js
+++ b/blocks/header/header.js
@@ -6,7 +6,7 @@ const metaFragment = !!metaHeader && `${metaHeader}.plain.html`;
export default class Header extends ComponentBase {
fragment = metaFragment || 'header.plain.html';
- dependencies = ['navigation'];
+ dependencies = ['navigation', 'image'];
extendConfig() {
return [
diff --git a/blocks/icon/icon.js b/blocks/icon/icon.js
index 72d8a4e5..247b2509 100644
--- a/blocks/icon/icon.js
+++ b/blocks/icon/icon.js
@@ -5,6 +5,15 @@ export default class Icon extends ComponentBase {
nestedComponentsConfig = {};
+ extendConfig() {
+ return [
+ ...super.extendConfig(),
+ {
+ contentFromTargets: false,
+ },
+ ];
+ }
+
constructor() {
super();
this.setupSprite();
diff --git a/blocks/navigation/navigation.js b/blocks/navigation/navigation.js
index b6874590..95401046 100644
--- a/blocks/navigation/navigation.js
+++ b/blocks/navigation/navigation.js
@@ -67,8 +67,7 @@ export default class Navigation extends ComponentBase {
async setupCompactedNav() {
if (!this.navCompactedContentInit) {
this.navCompactedContentInit = true;
- await Promise.all([component.loadAndDefine('accordion'), component.loadAndDefine('icon')]);
-
+ await component.multiLoadAndDefine(['accordion', 'icon']);
this.setupClasses(this.navCompactedContent, true);
this.navCompactedContent.addEventListener('click', (e) => this.activate(e));
}
diff --git a/scripts/component-base.js b/scripts/component-base.js
index c461fe43..247a3ea9 100644
--- a/scripts/component-base.js
+++ b/scripts/component-base.js
@@ -9,12 +9,13 @@ export default class ComponentBase extends HTMLElement {
constructor() {
super();
+ this.uuid = `gen${crypto.randomUUID().split('-')[0]}`;
this.componentName = null; // set by component loader
this.webComponentName = null; // set by component loader
this.fragment = false;
this.dependencies = [];
this.breakpoints = getBreakPoints();
- this.uuid = `gen${crypto.randomUUID().split('-')[0]}`;
+ this.initError = null;
this.attributesValues = {}; // the values are set by the component loader
this.setConfig('config', 'extendConfig');
this.setConfig('nestedComponentsConfig', 'extendNestedConfig');
@@ -36,6 +37,8 @@ export default class ComponentBase extends HTMLElement {
attributesValues = {}; // the values are set by the component loader
config = {
+ hideOnInitError: true,
+ hideOnNestedError: false,
addToTargetMethod: 'replaceWith',
contentFromTargets: true,
targetsAsContainers: {
@@ -43,6 +46,7 @@ export default class ComponentBase extends HTMLElement {
},
};
+ // Default values are set by component loader
nestedComponentsConfig = {
image: {
componentName: 'image',
@@ -114,12 +118,17 @@ export default class ComponentBase extends HTMLElement {
* In some cases a check for `this.initialized` inside `onAttribute${capitalizedAttr}Changed` might be required
*/
attributeChangedCallback(name, oldValue, newValue) {
- const camelAttr = camelCaseAttr(name);
- const capitalizedAttr = capitalizeCaseAttr(name);
- // handle case when attribute is removed from the element
- // default to attribute breakpoint value
- const defaultNewVal = newValue === null ? this.getBreakpointAttrVal(camelAttr) ?? null : newValue;
- this[`onAttribute${capitalizedAttr}Changed`]?.({ oldValue, newValue: defaultNewVal });
+ try {
+ const camelAttr = camelCaseAttr(name);
+ const capitalizedAttr = capitalizeCaseAttr(name);
+ // handle case when attribute is removed from the element
+ // default to attribute breakpoint value
+ const defaultNewVal = newValue === null ? this.getBreakpointAttrVal(camelAttr) ?? null : newValue;
+ this[`onAttribute${capitalizedAttr}Changed`]?.({ oldValue, newValue: defaultNewVal });
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(`There was an error while processing the '${name}' attribute change:`, this, error);
+ }
}
getBreakpointAttrVal(attr) {
@@ -134,42 +143,51 @@ export default class ComponentBase extends HTMLElement {
}
async connectedCallback() {
- this.initialized = this.getAttribute('initialized');
- this.initSubscriptions(); // must subscribe each time the element is added to the document
- if (!this.initialized) {
- this.setAttribute('id', this.uuid);
- await Promise.all([this.loadFragment(this.fragment), this.loadDependencies()]);
- await this.connected(); // manipulate/create the html
- await this.initNestedComponents();
- this.addListeners(); // html is ready add listeners
- await this.ready(); // add extra functionality
- this.setAttribute('initialized', true);
- this.initialized = true;
- this.dispatchEvent(new CustomEvent('initialized', { detail: { element: this } }));
+ try {
+ this.initialized = this.getAttribute('initialized');
+ this.initSubscriptions(); // must subscribe each time the element is added to the document
+ if (!this.initialized) {
+ this.setAttribute('id', this.uuid);
+ this.loadDependencies(); // do not wait for dependencies;
+ await this.loadFragment(this.fragment);
+ await this.connected(); // manipulate/create the html
+ await this.initNestedComponents();
+ this.addListeners(); // html is ready add listeners
+ await this.ready(); // add extra functionality
+ this.setAttribute('initialized', true);
+ this.initialized = true;
+ this.dispatchEvent(new CustomEvent(`initialized:${this.uuid}`, { detail: { element: this } }));
+ }
+ } catch (error) {
+ this.dispatchEvent(new CustomEvent(`initialized:${this.uuid}`, { detail: { error } }));
+ this.initError = error;
+ this.hideWithError(this.config.hideOnInitError, 'has-nested-error');
}
}
async initNestedComponents() {
- const nested = await Promise.all(
- Object.values(this.nestedComponentsConfig).flatMap(async (setting) => {
- if (!setting.active) return [];
- const s = this.fragment
- ? deepMerge({}, setting, {
- // Content can contain blocks which are going to init their own nestedComponents.
- loaderConfig: {
- targetsSelectorsPrefix: ':scope > div >', // Limit only to default content, exclude blocks.
- },
- })
- : setting;
- return component.init(s);
- }),
- );
- this.nestedElements = nested.flat();
+ const settings = Object.values(this.nestedComponentsConfig).flatMap((setting) => {
+ if (!setting.active) return [];
+ return this.fragment
+ ? deepMerge({}, setting, {
+ // Content can contain blocks which are going to init their own nestedComponents.
+ loaderConfig: {
+ targetsSelectorsPrefix: ':scope > div >', // Limit only to default content, exclude blocks.
+ },
+ })
+ : setting;
+ });
+ this.nestedComponents = await component.multiInit(settings);
+ const {
+ nestedComponents: { allInitialized },
+ config: { hideOnNestedError },
+ } = this;
+ this.hideWithError(!allInitialized && hideOnNestedError, 'has-nested-error');
}
async loadDependencies() {
if (!this.dependencies.length) return;
- await Promise.all(this.dependencies.map((dep) => component.loadAndDefine(dep)));
+ component.multiLoadAndDefine(this.dependencies);
}
async loadFragment(path) {
@@ -186,7 +204,18 @@ export default class ComponentBase extends HTMLElement {
if (response.ok) {
const html = await response.text();
this.innerHTML = html;
- await Promise.all([...this.querySelectorAll('div[class]')].map((block) => component.init({ targets: [block] })));
+ const fragmentNested = await component.multiInit(
+ [...this.querySelectorAll('div[class]')].map((block) => ({ targets: [block] })),
+ );
+ const { allInitialized } = fragmentNested;
+ this.hideWithError(!allInitialized && this.config.hideOnNestedError, 'has-nested-error');
+ }
+ }
+
+ hideWithError(check, statusAttr) {
+ if (check) {
+ this.classList.add('hide-with-error');
+ this.setAttribute(statusAttr, '');
}
}
diff --git a/scripts/component-loader.js b/scripts/component-loader.js
index 1411c692..64cf336c 100644
--- a/scripts/component-loader.js
+++ b/scripts/component-loader.js
@@ -4,9 +4,7 @@ export default class ComponentLoader {
constructor({ componentName, targets = [], loaderConfig, rawClasses, config, nestedComponentsConfig, active }) {
window.raqnComponents ??= {};
if (!componentName) {
- // eslint-disable-next-line no-console
- console.error('`componentName` is required');
- return;
+ throw new Error('`componentName` is required');
}
this.componentName = componentName;
this.targets = targets.map((target) => ({ target }));
@@ -40,41 +38,76 @@ export default class ComponentLoader {
}
async init() {
- if (this.active === false) return null;
- if (!this.componentName) return null;
- const loaded = await this.loadAndDefine();
- if (!loaded) return null;
+ if (this.active === false) return [];
+ if (!this.componentName) return [];
+ const {loaded, error} = await this.loadAndDefine();
+ if (!loaded) throw new Error(error);
this.setHandlerType();
- if (await this.Handler?.earlyStopRender?.()) return this.Handler;
- if (!this.targets?.length) return this.Handler;
+ if (await this.Handler?.earlyStopRender?.()) return [];
+ if (!this.targets?.length) return [];
this.setTargets();
- return Promise.all(
+ return Promise.allSettled(
this.targets.map(async (target) => {
+ let returnVal = null;
const data = this.getTargetData(target);
if (this.isWebComponent) {
- const elem = await this.createElementAndConfigure(data);
- data.componentElem = elem;
- this.addContentFromTarget(data);
- await this.connectComponent(data);
- return elem;
+ returnVal = this.initWebComponent(data);
}
if (this.isClass) {
- return new this.Handler({
- componentName: this.componentName,
- ...data,
- });
+ returnVal = this.initClass(data);
}
if (this.isFn) {
- return this.Handler(data);
+ returnVal = this.initFn(data);
}
- return null;
+ return returnVal;
}),
);
}
+ async initWebComponent(data) {
+ let returnVal = null;
+ try {
+ const elem = await this.createElementAndConfigure(data);
+ data.componentElem = elem;
+ returnVal = elem;
+ this.addContentFromTarget(data);
+ await this.connectComponent(data);
+ } catch (error) {
+ const err = new Error(error);
+ err.elem = returnVal;
+ // eslint-disable-next-line no-console
+ console.error(`There was an error while initializing the '${this.componentName}' webComponent:`, returnVal, error);
+ throw err;
+ }
+ return returnVal;
+ }
+
+ async initClass(data) {
+ try {
+ return new this.Handler({
+ componentName: this.componentName,
+ ...data,
+ });
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(`There was an error while initializing the '${this.componentName}' class:`, data.target, error);
+ throw error;
+ }
+ }
+
+ async initFn(data) {
+ try {
+ return this.Handler(data);
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(`There was an error while initializing the '${this.componentName}' function:`, data.target, error);
+ throw error;
+ }
+ }
+
getTargetData({ target, container }) {
return {
target,
@@ -158,21 +191,24 @@ export default class ComponentLoader {
const { contentFromTargets } = componentElem.config;
if (!contentFromTargets) return;
- componentElem.append(...target.children);
+ componentElem.append(...target.childNodes);
}
async connectComponent(data) {
const { componentElem } = data;
+ const { uuid } = componentElem;
componentElem.setAttribute('isloading', '');
- const initialized = new Promise((resolve) => {
+ const initialized = new Promise((resolve, reject) => {
const initListener = async (event) => {
- if (event.detail.element === componentElem) {
- componentElem.removeEventListener('initialized', initListener);
- componentElem.removeAttribute('isloading');
- resolve(componentElem);
+ const { error } = event.detail;
+ componentElem.removeEventListener(`initialized:${uuid}`, initListener);
+ componentElem.removeAttribute('isloading');
+ if (error) {
+ reject(error);
}
+ resolve(componentElem);
};
- componentElem.addEventListener('initialized', initListener);
+ componentElem.addEventListener(`initialized:${uuid}`, initListener);
});
const { targetsAsContainers } = this.loaderConfig;
const conf = componentElem.config;
@@ -198,11 +234,11 @@ export default class ComponentLoader {
}
this.Handler = await this.Handler;
await cssLoaded;
- return true;
+ return { loaded: true };
} catch (error) {
// eslint-disable-next-line no-console
- console.error(`failed to load module for ${this.componentName}`, error);
- return false;
+ console.error(`Failed to load module for ${this.componentName}:`, error);
+ return { loaded: false, error };
}
}
}
diff --git a/scripts/init.js b/scripts/init.js
index aa032c9e..78aea1fd 100644
--- a/scripts/init.js
+++ b/scripts/init.js
@@ -1,16 +1,62 @@
import ComponentLoader from './component-loader.js';
-import { globalConfig, eagerImage, getMeta, getMetaGroup } from './libs.js';
+import { globalConfig, eagerImage, getMeta, getMetaGroup, mergeUniqueArrays } from './libs.js';
const component = {
async init(settings) {
- return new ComponentLoader({
- ...settings,
- componentName: settings.componentName ?? this.getBlockData(settings?.targets?.[0]).componentName,
- }).init();
+ const { componentName = this.getBlockData(settings?.targets?.[0]).componentName } = settings || {};
+ try {
+ const loader = new ComponentLoader({
+ ...settings,
+ componentName,
+ });
+ const instances = await loader.init();
+
+ const init = {
+ componentName,
+ instances: [],
+ failedInstances: [],
+ };
+
+ instances.forEach((data) => {
+ if (data.status === 'fulfilled') init.instances.push(data.value);
+ if (data.reason) init.failedInstances.push(data.reason.elem || data.reason);
+ });
+ return init;
+ } catch (error) {
+ const init = {
+ componentName,
+ initError: error,
+ };
+ // eslint-disable-next-line no-console
+ console.error(`There was an error while initializing the ${componentName} component`, error);
+ return init;
+ }
+ },
+
+ async multiInit(settings) {
+ const initializing = await Promise.allSettled(settings.map((s) => this.init(s)));
+ const initialized = initializing.map((data) => data.value || data.reason);
+ const status = {
+ allInitialized: initialized.every((c) => !(c.initError || c.failedInstances.length)),
+ instances: initialized,
+ };
+ return status;
},
async loadAndDefine(componentName) {
- await new ComponentLoader({ componentName }).loadAndDefine();
+ const status = await new ComponentLoader({ componentName }).loadAndDefine();
+ return { componentName, status };
+ },
+
+ async multiLoadAndDefine(componentNames) {
+ const loading = await Promise.allSettled(componentNames.map((n) => this.loadAndDefine(n)));
+ const loaded = loading.map((data) => data.value || data.reason);
+ const status = {
+ allLoaded: loaded.every((m) => m.status.loaded),
+ modules: loaded,
+ };
+
+ return status;
},
getBlockData(block) {
@@ -20,7 +66,7 @@ const component = {
if (!globalConfig.semanticBlocks.includes(tagName)) {
componentName = block.classList.item(0);
}
- return { block, componentName, lcp };
+ return { targets: [block], componentName, lcp };
},
};
@@ -77,11 +123,13 @@ const onLoadComponents = {
},
setLcp() {
- const lcpMeta = getMeta('lcp');
- const defaultLcp = ['theme', 'header', 'breadcrumbs'];
- this.lcp = lcpMeta?.length
- ? lcpMeta.split(',').map((componentName) => ({ componentName: componentName.trim() }))
- : defaultLcp;
+ const lcpMeta = getMeta('lcp', { getArray: true });
+ const defaultLcp = ['theming', 'header', 'breadcrumbs'];
+ const lcp = lcpMeta?.length ? lcpMeta : defaultLcp;
+ // theming must be in LCP to prevent CLS
+ this.lcp = mergeUniqueArrays(lcp, ['theming']).map((componentName) => ({
+ componentName: componentName.trim(),
+ }));
},
setStructure() {
@@ -105,21 +153,15 @@ const onLoadComponents = {
findLcp(data) {
return (
this.lcp.find(({ componentName }) => componentName === data.componentName) || data.lcp /* ||
- [...document.querySelectorAll('main > div > [class]:nth-child(-n+2)')].find((el) => el === data.block) */
+ [...document.querySelectorAll('main > div > [class]:nth-child(-n+1)')].find((el) => el === data?.targets?.[0]) */
);
},
initBlocks() {
- Promise.all(
- this.lcpBlocks.map(async ({ componentName, block, loaderConfig }) =>
- component.init({ componentName, targets: [block], loaderConfig }),
- ),
- ).then(() => {
- document.body.style.display = 'unset';
+ component.multiInit(this.lcpBlocks).then(() => {
+ document.body.style.setProperty('display', 'unset');
});
- this.lazyBlocks.map(({ componentName, block, loaderConfig }) =>
- setTimeout(() => component.init({ componentName, targets: [block], loaderConfig })),
- );
+ component.multiInit(this.lazyBlocks);
},
};
diff --git a/scripts/libs.js b/scripts/libs.js
index 57d8706c..e39c170b 100644
--- a/scripts/libs.js
+++ b/scripts/libs.js
@@ -116,7 +116,7 @@ export const eagerImage = (block, length = 1) => {
};
export function stringToJsVal(string) {
- switch (string.trim()) {
+ switch (string?.trim().toLowerCase()) {
case 'true':
return true;
case 'false':
@@ -130,12 +130,18 @@ export function stringToJsVal(string) {
}
}
-export function getMeta(name) {
+export function getMeta(name, settings) {
+ const { getArray = false } = settings || {};
const meta = document.querySelector(`meta[name="${name}"]`);
if (!meta) {
return null;
}
- return stringToJsVal(meta.content);
+ const val = stringToJsVal(meta.content);
+ if (getArray) {
+ if (!val?.length) return [];
+ return val.split(',').map((x) => x.trim());
+ }
+ return val;
}
export function getMetaGroup(group) {
@@ -262,7 +268,7 @@ export function loadModule(urlWithoutExtension) {
}
}).catch((error) =>
// eslint-disable-next-line no-console
- console.trace('could not load module style', urlWithoutExtension, error),
+ console.error('could not load module style', urlWithoutExtension, error),
);
return { css, js };
diff --git a/styles/styles.css b/styles/styles.css
index ddbcea56..c671a642 100644
--- a/styles/styles.css
+++ b/styles/styles.css
@@ -71,9 +71,10 @@ header {
background: var(--scope-header-background, #fff);
}
-/* main {
- margin-top: var(--scope-header-height, 64px);
-} */
+head:has(meta[name="header"][content="false" i]) + body > header,
+head:has(meta[name="footer"][content="false" i]) + body > footer {
+ display: none;
+}
main > div {
max-width: var(--scope-max-width, 100%);
@@ -156,6 +157,7 @@ img {
[isloading],
+.hide-with-error,
.hide {
display: none;
pointer-events: none;