aem-boilerplate icon indicating copy to clipboard operation
aem-boilerplate copied to clipboard

feat: a minimal plugin and template system

Open ramboz opened this issue 1 year ago • 14 comments

Use case

As a developer, I want to be able to easily organize my code into different files, and execute some of the logic in each phase of the page load as needed.

In particular, I want a way to:

  • easily define templates that have some JS logic
  • add plugins for more advanced features like experimentation, conversion tracking, tag management, etc.
  • load templates and plugins only when they are needed
  • control in what phase a plugin is loaded to avoid negatively impacting performances if it is not needed immediately

Technical Requirements

  • plugins and templates should use the same logic to reduce code duplication
  • plugins and templates should re-use the block loading approach to load both JS and CSS files, if applicable
  • plugins should offer a way to "override" code methods, for instance to add icon spriting on top of regular icon decoration
  • developers should be able to decide where exactly in each phase the plugins are run so they can control pre/post behavior according to the project needs
  • performance impact should be negligible
  • plugins and templates should offer a minimal API on the window.hlx.* namespace
  • the system should help in making plugins and templates easier to unit test (using mostly pure functions)

Proposal

Introduce 2 new namespaces on window.hlx.*:

  • window.hlx.templates
    • add(id, url): register a new template
    • get(id): get the template
    • includes(id): check if a template exists
  • window.hlx.plugins
    • add(id, config): add a new plugin
    • get(id): get the plugin
    • includes(id): check if a plugin exists
    • load(phase): load all plugins for the current phase (eager, lazy or delayed)
    • run(method): runs the specified method on the plugins (loadEager, loadLazy or loadDelayed)

Plugins and templates would follow the same API:

  • export default function init(document, options): logic executed immediately when the plugin/template is loaded
  • export function loadEager(document, options): logic executed in the eager phase
  • export function loadLazy(document, options): logic executed in the lazy phase
  • export function loadDelayed(document, options): logic executed in the delayed phase

where this is set to an execution context that contains most lib-franklin.js helper functions, so we can reduce imports in the plugins (makes it easier to mock in tests, and also avoids dependency cycles).

Or the slightly more verbose version below for arrow functions:

  • export default (document, options, context) => {}
  • export const loadEager = (document, options, context) => {}
  • export const loadLazy = (document, options, context) => {}
  • export const loadDelayed = (document, options, context) => {}

ℹ️ All those exports are optional and are only executed if present.

Extracting a new loadModule(name, cssPath, jsPath, args) method that both the loadBlock and the new plugin system use.

Usage

Adding Templates

Add a new template to the project:

window.hlx.templates.add('foo', '/templates/foo.js');

or the shorthand version:

window.hlx.templates.add('/templates/bar.js');

or the bulk version:

window.hlx.templates.add([
  '/templates/bar.js',
  '/templates/baz.js'
]);

or the the module approach that loads both js and css:

window.hlx.templates.add('/templates/bar');
// loads both /templates/bar/bar.css and /templates/bar/bar.js

Adding Plugins

Add a new inline plugin to the project:

window.hlx.plugins.add('inline-plugin-baz', {
  condition: () => true, // if defined, the condition is evaluated before running any code in the plugin
  loadEager: () => { … },
  loadLazy: () => { … },
  loadDelayed: () => { … },
});

Add a regular plugin to the project that will always load (no condition):

window.hlx.plugins.add('plugin-qux', { url: '/plugins/qux/src/index.js' });

or the module approach that loads both js and CSS:

window.hlx.plugins.add('plugin-qux', { url: '/plugins/qux' });

or the shorthand version:

window.hlx.plugins.add('/plugins/qux');

Add a plugin that only loads in the lazy phase and also passes some additional options:

window.hlx.plugins.add('plugin-qux', {
  url: '/plugins/qux/src/index.js',
  load: 'lazy', // can be `eager`, `lazy` or `delayed`. defaults to `eager` if omitted
  options: { corge: 'grault' },
});

Once plugins have been loaded via window.hlx.plugins.load(phase), we can then run individual methods as required via window.hlx.plugins.run('loadEager'), etc.

Plugin Template

/plugins/qux.js

// document: to keep it consistent with the loadEager in the scripts.js file
// options: additional options passed to the plugin when it was added
// context: passes a plugin context which gives access to the main lib-franklin.js APIs and avoids cyclic dependencies
//          it also turns all of those into pure functions that are easier to unit test
export default (document, options, context) => {
  console.log('plugin qux: init', options, context);
};

export const loadEager = (document, options, context) => {
  console.log('plugin qux: eager', options, context);
};

export const loadLazy = (document, options, context) => {
  console.log('plugin qux: lazy', options, context);
};

export const loadDelayed = (document, options, context) => {
  console.log('plugin qux: delayed', options, context);
};

~When using proper functions, the this variable is set to the context, so we can ignore the last parameter. This is mostly for backward compatibility with existing approaches that were passing it down that way until now, but also offers a slightly shorter API.~ Or with proper functions: /plugins/qux.js

// document: to keep it consistent with the loadEager in the scripts.js file
// options: additional options passed to the plugin when it was added
// context: passes a plugin context which gives access to the main lib-franklin.js APIs and avoids cyclic dependencies
//          it also turns all of those into pure functions that are easier to unit test
export default function init(document, options, context) {
  console.log('plugin qux: init', options, context);
};

export function loadEager(document, options, context) {
  console.log('plugin qux: eager', options, context);
};

export function loadLazy(document, options, context) {
  console.log('plugin qux: lazy', options, context);
};

export function loadDelayed(document, options, context) {
  console.log('plugin qux: delayed', options, context);
};

Examples

A barebone demo site built for this PR:

  • https://github.com/ramboz/helix-plugins-demo/blob/main/scripts/scripts.js#L18-L49
  • https://main--helix-plugins-demo--ramboz.hlx.page/

We have a few sites that are being instrumented with this:

  • https://github.com/hlxsites/wknd/pull/31
  • https://github.com/adobe/helix-website/pull/405
  • https://github.com/pfizer/libraryfranklinpfizer/pull/267
  • https://github.com/hlxsites/petplace/pull/316
  • https://github.com/BambooHR/bamboohr-website/pull/602
  • https://github.com/hlxsites/bitdefender/pull/398
  • https://github.com/hlxsites/danaher-ls-aem/pull/307
  • https://github.com/hlxsites/mammotome/pull/559
  • https://github.com/hlxsites/moleculardevices/pull/1263
  • https://github.com/hlxsites/maidenform/pull/646
  • https://github.com/hlxsites/24petwatch-crosswalk/pull/7

We also do have a few plugins that already follow this API:

  • https://github.com/adobe/aem-experimentation
  • https://github.com/adobe/aem-rum-conversion
  • https://github.com/adobe/aem-martech-loader

As well as a few template mechanisms that could leverage it:

  • https://github.com/hlxsites/panera/blob/94c980384caf25cf45848982e1b1b4fe433b41f8/scripts/scripts.js#L76-L80
  • https://github.com/hlxsites/vg-macktrucks-com/blob/032be707c746aa121a2c0efa3f529f3c1770532f/scripts/scripts.js#L284C16-L308

Those could all be aligned on a consistent system.

Test URLs:

  • Before: https://main--helix-project-boilerplate--adobe.hlx.page/
  • After: https://main--helix-plugins-demo--ramboz.hlx.page/

ramboz avatar Sep 06 '23 00:09 ramboz

Hello, I'm Franklin Bot and I will run some test suites that validate the page speed. In case there are problems, just click the checkbox below to rerun the respective action.

  • [ ] Re-run PSI Checks

aem-code-sync[bot] avatar Sep 06 '23 00:09 aem-code-sync[bot]

Page Scores Audits Google
/ PERFORMANCE A11Y SEO BEST PRACTICES SI FCP LCP TBT CLS PSI

aem-code-sync[bot] avatar Sep 06 '23 00:09 aem-code-sync[bot]

Performance comparison: Screenshot 2023-09-15 at 9 32 19 AM

At first glance, there is no noticeable impact. Plugin version is even slightly faster, but that is likely just the variance in the test

ramboz avatar Sep 15 '23 16:09 ramboz

@kptdobe Shall I also port this over to aem-lib? Or should we first validate and discuss the approach here for a bit?

ramboz avatar Sep 18 '23 21:09 ramboz

I think it is fine to discuss it here for now because this goes beyond aem-lib and has / should have an impact on the boilerplate. While I understand the idea, I do not really understand some of the PR content with the foo.js, bar.js, grault, qux... I assume this is not meant to be merged and is here as an example. This makes the whole PR hard to read to know what is needed, what is a sample (maybe we should create a sample project based on the PR boilerplate illustrating the extension capabilities)

While migrating aem-lib, I realised it contains mainly "helper" methods and we still have the core of the loading sequence and logic in the boilerplate. This is the key element for high performance sites and this is given to all projects as part of script.js. Also, we tell all customers this is the place they have to change stuff to cover some of their project needs, i.e. it is easy for them customise / broke the loading sequence.

I like the plugin idea if this is initially used as part of the boilerplate to protect the loading sequence but still allows some level of customisation of the initial sequence + add your own custom logic. In other terms, scripts.js should become minimal to what is fundamentally project specific (load header, load footer ?) using the plugin approach.

kptdobe avatar Sep 19 '23 06:09 kptdobe

@kptdobe Agree on all point! And yes, the foo/bar stuff are just examples. I'll work on creating a cleaner demo, so it's easier to read

ramboz avatar Sep 25 '23 08:09 ramboz

@kptdobe All done. PR should be cleaner now and the project-level logic is now in a separate repo.

I initially also wanted to move the page loading logic into the lib, and only expose hooks in the scripts.js, but I had some pushback from @davidnuescheler so preferred to defer this to a later iteration. Also helps reduce the scope a bit.

ramboz avatar Sep 25 '23 18:09 ramboz

Page Scores Audits Google
/ PERFORMANCE A11Y SEO BEST PRACTICES SI FCP LCP TBT CLS PSI
/?0.5814969076944914 PERFORMANCE A11Y SEO BEST PRACTICES SI FCP LCP TBT CLS PSI
/?0.7093454368913195 PERFORMANCE A11Y SEO BEST PRACTICES SI FCP LCP TBT CLS PSI

aem-code-sync[bot] avatar Sep 26 '23 09:09 aem-code-sync[bot]

Page Scores Audits Google
/ PERFORMANCE A11Y SEO BEST PRACTICES SI FCP LCP TBT CLS PSI
/?0.7093454368913195 PERFORMANCE A11Y SEO BEST PRACTICES SI FCP LCP TBT CLS PSI
/?0.5814969076944914 PERFORMANCE A11Y SEO BEST PRACTICES SI FCP LCP TBT CLS PSI

aem-code-sync[bot] avatar Sep 26 '23 09:09 aem-code-sync[bot]

Page Scores Audits Google
/ PERFORMANCE A11Y SEO BEST PRACTICES SI FCP LCP TBT CLS PSI
/?0.5814969076944914 PERFORMANCE A11Y SEO BEST PRACTICES SI FCP LCP TBT CLS PSI
/?0.7093454368913195 PERFORMANCE A11Y SEO BEST PRACTICES SI FCP LCP TBT CLS PSI

aem-code-sync[bot] avatar Sep 26 '23 14:09 aem-code-sync[bot]

@davidnuescheler @rofe @kptdobe @fkakatie Would love to have your thoughts on the current proposal to see how to fine-tune this.

ramboz avatar Oct 05 '23 09:10 ramboz

The current is:

  • we agree on the current architecture proposal
  • @ramboz and team will adjust their plugins to this architecture and deploy the whole thing to 3 projects
  • if everything works as expected, we'll work on the final PR: still requires to explose this PR in 2 pieces, one for https://github.com/adobe/aem-boilerplate, and one for https://github.com/adobe/lib-aem

kptdobe avatar Oct 16 '23 15:10 kptdobe

And we also agreed to remove the this context hack on proper functions in favor of a more consistent use of the context last parameter on the main exports

ramboz avatar Oct 25 '23 08:10 ramboz

[WIP] Follow-up PRs:

  • https://github.com/adobe/aem-lib/pull/23
  • https://github.com/adobe/aem-boilerplate/pull/275

ramboz avatar Oct 26 '23 09:10 ramboz

@ramboz any update on this?

rofe avatar May 25 '24 11:05 rofe

@rofe We've tried it on a dozen websites so far, and it's working fine. But I remember discussions with @trieloff to try to simplify this and leverage more the CustomEvent API. This would also align better with how the RUM library has evolved. I'm planning on having a revised proposal accordingly… but it's not exactly at the top of my priority list at the moment.

ramboz avatar May 26 '24 15:05 ramboz

Closing in favor of #374

ramboz avatar Jun 21 '24 23:06 ramboz