@cocreate/plugins 1.8.0 → 2.0.0

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/index.js CHANGED
@@ -2,13 +2,18 @@ import Observer from '@cocreate/observer';
2
2
  import { astAsync } from '@cocreate/utils';
3
3
 
4
4
  const scriptCache = new Map();
5
- const cssMarker = typeof document !== 'undefined'
5
+ const pendingElements = new WeakSet();
6
+ const initializedPlugins = new WeakMap();
7
+ const initializingPlugins = new WeakMap();
8
+ const cssMarker = typeof document !== 'undefined'
6
9
  ? document.head.querySelector('meta[name="viewport"]') || document.head.lastChild
7
10
  : null;
8
11
 
9
12
  /**
10
13
  * Retrieves the global plugin configuration object from window.CoCreateConfig.
11
14
  * @returns {Object}
15
+ * @private
16
+ * @environment client
12
17
  */
13
18
  function getPluginsConfig() {
14
19
  if (typeof window !== 'undefined' && window.CoCreateConfig && window.CoCreateConfig.plugins) {
@@ -21,6 +26,8 @@ function getPluginsConfig() {
21
26
  * Dynamically registers or updates a plugin definition in global config.
22
27
  * @param {string} name - Plugin identifier
23
28
  * @param {Object} config - Plugin configuration object (js, css, etc.)
29
+ * @public
30
+ * @environment client
24
31
  */
25
32
  export function registerPlugin(name, config) {
26
33
  if (typeof window !== 'undefined') {
@@ -35,6 +42,8 @@ export function registerPlugin(name, config) {
35
42
  * Sets crossOrigin="anonymous" by default for simple string URLs.
36
43
  * @param {string|Object} item - Script URL or object with configuration
37
44
  * @returns {Promise<void>}
45
+ * @private
46
+ * @environment client
38
47
  */
39
48
  function loadScript(item) {
40
49
  const src = typeof item === 'string' ? item : item?.src;
@@ -74,6 +83,8 @@ function loadScript(item) {
74
83
  * Sets crossOrigin="anonymous" by default for simple string URLs.
75
84
  * @param {string|Object} item - Stylesheet URL or object with configuration
76
85
  * @returns {Promise<void>}
86
+ * @private
87
+ * @environment client
77
88
  */
78
89
  function loadCSS(item) {
79
90
  const href = typeof item === 'string' ? item : item?.href;
@@ -110,8 +121,11 @@ function loadCSS(item) {
110
121
 
111
122
  /**
112
123
  * Preloads JS scripts and CSS stylesheets declared in global plugins config.
113
- * Runs in parallel using Promise.allSettled.
124
+ * Loads each plugin's JavaScript resources in declared order while allowing
125
+ * independent plugins and stylesheets to load in parallel.
114
126
  * @param {Array<string>} pluginNames - Names of target plugins to preload
127
+ * @private
128
+ * @environment client
115
129
  */
116
130
  async function preloadResources(pluginNames) {
117
131
  const plugins = getPluginsConfig();
@@ -126,7 +140,11 @@ async function preloadResources(pluginNames) {
126
140
  }
127
141
  if (config.js) {
128
142
  const jsFiles = Array.isArray(config.js) ? config.js : [config.js];
129
- jsFiles.forEach(src => promises.push(loadScript(src)));
143
+ promises.push((async () => {
144
+ for (const src of jsFiles) {
145
+ await loadScript(src);
146
+ }
147
+ })());
130
148
  }
131
149
  }
132
150
  }
@@ -145,6 +163,8 @@ async function preloadResources(pluginNames) {
145
163
  * @param {number} retries - Number of retries
146
164
  * @param {number} delay - Retry interval in ms
147
165
  * @returns {Promise<Function|Object|null>}
166
+ * @private
167
+ * @environment client
148
168
  */
149
169
  async function waitForPluginOnWindow(pluginName, retries = 5, delay = 50) {
150
170
  for (let i = 0; i < retries; i++) {
@@ -159,6 +179,8 @@ async function waitForPluginOnWindow(pluginName, retries = 5, delay = 50) {
159
179
  * Helper to resolve and normalize plugin library constructor from window object.
160
180
  * @param {string} pluginName - The target plugin identifier
161
181
  * @returns {Function|Object|null}
182
+ * @private
183
+ * @environment client
162
184
  */
163
185
  function getPluginLibrary(pluginName) {
164
186
  if (typeof window === 'undefined') return null;
@@ -171,18 +193,53 @@ function getPluginLibrary(pluginName) {
171
193
  return null;
172
194
  }
173
195
 
196
+ function getPluginNames(configBlock) {
197
+ const configuredPlugins = getPluginsConfig();
198
+ const pluginNames = new Set();
199
+
200
+ function collect(value) {
201
+ if (!value || typeof value !== 'object') return;
202
+
203
+ for (const [key, childValue] of Object.entries(value)) {
204
+ const name = key.replace(/^\$/, '').split('.')[0];
205
+ if (configuredPlugins[name] || getPluginLibrary(name)) {
206
+ pluginNames.add(name);
207
+ }
208
+ collect(childValue);
209
+ }
210
+ }
211
+
212
+ collect(configBlock);
213
+
214
+ return pluginNames;
215
+ }
216
+
174
217
  /**
175
218
  * Parses element 'plugin' attribute, executes AST configuration via astAsync,
176
- * sets core library on window, and attaches instance onto element property.
219
+ * with result storage controlled explicitly by the AST configuration.
177
220
  * @param {Element} el - DOM element with plugin attribute
221
+ * @private
222
+ * @environment client
178
223
  */
179
- async function processPlugin(el) {
180
- if (!el || !el.attributes) return;
224
+ function processPlugin(el) {
225
+ if (!el || !el.attributes) return Promise.resolve();
181
226
 
182
227
  const pluginAttrNode = el.attributes['plugin'] || el.attributes.getNamedItem?.('plugin');
183
228
  const rawPluginAttr = pluginAttrNode?.value?.trim();
184
- if (!rawPluginAttr) return;
229
+ if (!rawPluginAttr) return Promise.resolve();
230
+
231
+ const pending = initializingPlugins.get(el);
232
+ if (pending?.value === rawPluginAttr) return pending.promise;
233
+ if (initializedPlugins.get(el) === rawPluginAttr) return Promise.resolve();
234
+
235
+ const promise = initializePlugin(el, rawPluginAttr).finally(() => {
236
+ if (initializingPlugins.get(el)?.promise === promise) initializingPlugins.delete(el);
237
+ });
238
+ initializingPlugins.set(el, { value: rawPluginAttr, promise });
239
+ return promise;
240
+ }
185
241
 
242
+ async function initializePlugin(el, rawPluginAttr) {
186
243
  try {
187
244
  let configs = [];
188
245
 
@@ -190,65 +247,41 @@ async function processPlugin(el) {
190
247
  const parsedConfig = JSON.parse(rawPluginAttr);
191
248
  configs = Array.isArray(parsedConfig) ? parsedConfig : [parsedConfig];
192
249
  } else {
193
- configs = [{ [rawPluginAttr]: [] }];
250
+ configs = [{ ['$' + rawPluginAttr.replace(/^\$/, '')]: [] }];
194
251
  }
195
252
 
196
- const targetPlugins = new Set();
197
- configs.forEach(conf => {
198
- Object.keys(conf).forEach(k => {
199
- if (!k.startsWith('\u0024')) {
200
- targetPlugins.add(k.split('.')[0]);
201
- }
202
- });
203
- });
253
+ const configPlans = configs.map(configBlock => ({
254
+ configBlock,
255
+ pluginNames: getPluginNames(configBlock)
256
+ }));
257
+ const targetPlugins = new Set(
258
+ configPlans.flatMap(({ pluginNames }) => Array.from(pluginNames))
259
+ );
204
260
 
205
261
  await preloadResources(Array.from(targetPlugins));
206
262
 
207
- for (const configBlock of configs) {
208
- for (const [pluginName, astValue] of Object.entries(configBlock)) {
209
- // Poll window to ensure script execution has completed
210
- const pluginLib = await waitForPluginOnWindow(pluginName);
211
-
212
- // Ensure plugin library definition is attached to window once loaded
213
- if (pluginLib && typeof window !== 'undefined' && !window[pluginName]) {
214
- window[pluginName] = pluginLib;
215
- }
263
+ for (const { configBlock, pluginNames } of configPlans) {
264
+ const libraries = {};
216
265
 
217
- // Format operator key with $ for AST engine recognition (e.g. SimpleBar -> $SimpleBar)
218
- const operatorName = pluginName.startsWith('\u0024') ? pluginName : `$${pluginName}`;
219
-
220
- // Create constructor wrapper to safely instantiate class/function
221
- const opWrapper = function(params) {
222
- const args = Array.isArray(params) ? params : [params];
223
- if (typeof pluginLib === 'function') {
224
- try {
225
- return new pluginLib(...args);
226
- } catch (e) {
227
- return pluginLib(...args);
228
- }
229
- }
230
- return pluginLib;
231
- };
232
-
233
- // Provide operator wrapper in context for AST registry lookup
234
- const context = {
235
- [operatorName]: opWrapper
236
- };
237
-
238
- // Execute AST with the $ operator key so astAsync invokes operatorKey logic
239
- const result = await astAsync({
240
- node: el,
241
- sandbox: context,
242
- executeAST: { [operatorName]: astValue }
243
- });
244
-
245
- if (result !== undefined && !pluginName.startsWith('\u0024')) {
246
- // Attach returned plugin instance directly onto element property
247
- el[pluginName] = result;
266
+ for (const pluginName of pluginNames) {
267
+ const library = await waitForPluginOnWindow(pluginName);
268
+ if (!library) {
269
+ throw new Error(`Plugin '${pluginName}' is not available on window.`);
248
270
  }
271
+ libraries['$' + pluginName] = library;
249
272
  }
273
+
274
+ const scope = { ...libraries, $this: el };
275
+
276
+ await astAsync({
277
+ executeAST: configBlock,
278
+ sandbox: scope,
279
+ node: el
280
+ });
250
281
  }
282
+ initializedPlugins.set(el, rawPluginAttr);
251
283
  } catch (err) {
284
+ initializedPlugins.delete(el);
252
285
  console.error('[Plugin System] Failed to parse and execute plugin configuration:', err, rawPluginAttr);
253
286
  }
254
287
  }
@@ -256,23 +289,37 @@ async function processPlugin(el) {
256
289
  /**
257
290
  * Initializes plugins on single elements, collections, or selector query matches.
258
291
  * @param {Element|NodeList|Array|string} elements - Target element(s) or DOM query selector
292
+ * @public
293
+ * @environment client
259
294
  */
260
295
  export function init(elements) {
261
- if (!elements) return;
296
+ if (!elements) return Promise.resolve([]);
262
297
 
263
298
  if (typeof elements === 'string') {
264
299
  elements = document.querySelectorAll(elements);
265
300
  }
266
301
 
267
302
  if (elements instanceof Element) {
268
- processPlugin(elements);
303
+ return processPlugin(elements);
269
304
  } else if (elements.length !== undefined || Array.isArray(elements)) {
270
- Array.from(elements).forEach(el => {
271
- if (el instanceof Element) {
272
- processPlugin(el);
273
- }
274
- });
305
+ return Promise.all(
306
+ Array.from(elements)
307
+ .filter(el => el instanceof Element)
308
+ .map(el => processPlugin(el))
309
+ );
275
310
  }
311
+
312
+ return Promise.resolve([]);
313
+ }
314
+
315
+ function scheduleInit(element) {
316
+ if (!element || pendingElements.has(element)) return;
317
+
318
+ pendingElements.add(element);
319
+ requestAnimationFrame(() => {
320
+ pendingElements.delete(element);
321
+ if (element.isConnected) init(element);
322
+ });
276
323
  }
277
324
 
278
325
  if (typeof document !== 'undefined') {
@@ -282,10 +329,42 @@ if (typeof document !== 'undefined') {
282
329
  types: ["addedNodes", "attributes"],
283
330
  selector: selector,
284
331
  attributeFilter: ["plugin"],
285
- callback: (mutation) => init(mutation.target)
332
+ callback: (mutation) => {
333
+ scheduleInit(mutation.target);
334
+ }
286
335
  });
287
336
 
288
337
  init(document.querySelectorAll(selector));
289
338
  }
290
339
 
291
- export default { init, registerPlugin };
340
+ export default { init, registerPlugin };
341
+
342
+ /**
343
+ *
344
+ * @name plugin
345
+ * @kind member
346
+ * @attribute
347
+ * @environment client
348
+ */
349
+
350
+ /**
351
+ * CoCreate plugins
352
+ *
353
+ * @module plugins
354
+ * @environment client
355
+ */
356
+
357
+ /**
358
+ * Declarative selectors recognized by this module.
359
+ *
360
+ * @name declarative-selectors
361
+ * @kind member
362
+ * @selectors
363
+ * @environment client
364
+ * @example
365
+ * ```json
366
+ * [
367
+ * "[plugin]"
368
+ * ]
369
+ * ```
370
+ */
@@ -0,0 +1,212 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import vm from 'node:vm';
4
+ import test from 'node:test';
5
+ import { astAsync } from '@cocreate/utils';
6
+
7
+ const code = fs.readFileSync(new URL('../src/index.js', import.meta.url), 'utf8')
8
+ .replace(/^import .*;\r?\n/gm, '')
9
+ .replace(/^export /gm, '')
10
+ .replace('default { init, registerPlugin };', 'globalThis.pluginInit = init;');
11
+
12
+ class Element {
13
+ constructor(config) {
14
+ this.attributes = { plugin: { value: typeof config === 'string' ? config : JSON.stringify(config) } };
15
+ }
16
+ }
17
+
18
+ function runtime(libraries, evaluate = astAsync) {
19
+ const errors = [];
20
+ const context = {
21
+ astAsync: evaluate, Element,
22
+ window: { ...libraries, Object },
23
+ console: { error: (...args) => errors.push(args), warn: (...args) => errors.push(args) }
24
+ };
25
+ vm.runInNewContext(code, context);
26
+ return {
27
+ async init(element) {
28
+ await context.pluginInit(element);
29
+ assert.deepEqual(errors, []);
30
+ }
31
+ };
32
+ }
33
+
34
+ test('bare plugin initialization does not attach its returned instance', async () => {
35
+ let calls = 0;
36
+ class ExamplePlugin {
37
+ constructor() { calls++; }
38
+ }
39
+ const app = runtime({ ExamplePlugin });
40
+ const el = new Element('ExamplePlugin');
41
+ await app.init(el);
42
+ await app.init(el);
43
+ assert.equal(calls, 1);
44
+ assert.equal(Object.hasOwn(el, 'ExamplePlugin'), false);
45
+ });
46
+
47
+ test('nested constructors and $return results do not attach automatically', async () => {
48
+ class Widget {}
49
+ const app = runtime({ Library: { Widget } });
50
+ for (const config of [
51
+ { '$Library.Widget': [] },
52
+ { $return: { '$Library.Widget': [] } }
53
+ ]) {
54
+ const el = new Element(config);
55
+ await app.init(el);
56
+ assert.equal(Object.hasOwn(el, 'Library'), false);
57
+ }
58
+ });
59
+
60
+ test('AST can explicitly store a returned instance on $this under a chosen key', async () => {
61
+ class ExamplePlugin {}
62
+ const app = runtime({ ExamplePlugin });
63
+ const el = new Element({
64
+ '$this.customInstance': { $ExamplePlugin: [] }
65
+ });
66
+ await app.init(el);
67
+ assert.ok(el.customInstance instanceof ExamplePlugin);
68
+ assert.equal(Object.hasOwn(el, 'ExamplePlugin'), false);
69
+ });
70
+
71
+ test('AST can store results elsewhere and call methods on explicit references', async () => {
72
+ class ExamplePlugin {
73
+ render() { this.rendered = true; }
74
+ }
75
+ const destination = {};
76
+ const app = runtime({ ExamplePlugin, destination });
77
+ const el = new Element({
78
+ instance: { $ExamplePlugin: [] },
79
+ '$instance.render': [],
80
+ $return: { '$destination': { '$Object.assign': ['$destination', { instance: '$instance' }] } }
81
+ });
82
+ await app.init(el);
83
+ assert.ok(destination.instance instanceof ExamplePlugin);
84
+ assert.equal(destination.instance.rendered, true);
85
+ assert.equal(Object.hasOwn(el, 'ExamplePlugin'), false);
86
+ assert.equal(Object.hasOwn(el, 'instance'), false);
87
+ });
88
+
89
+ test('subsequent operations keep referencing a global library after a call returns a value', async () => {
90
+ let calls = 0;
91
+ function GlobalPlugin() { return {}; }
92
+ GlobalPlugin.highlightAll = () => { calls++; };
93
+ const app = runtime({ GlobalPlugin });
94
+ const el = new Element({ '$GlobalPlugin': [], '$GlobalPlugin.highlightAll': [] });
95
+ await app.init(el);
96
+ assert.equal(calls, 1);
97
+ assert.equal(Object.hasOwn(el, 'GlobalPlugin'), false);
98
+ });
99
+
100
+ test('passes the entire unchanged configuration to AST once with the host and library scope', async () => {
101
+ const config = {
102
+ created: { $ExamplePlugin: ['$this'] },
103
+ '$this.instance': '$created',
104
+ '$this.instance.render': []
105
+ };
106
+ class ExamplePlugin {
107
+ constructor(host) { this.host = host; }
108
+ render() { this.rendered = true; }
109
+ }
110
+ const calls = [];
111
+ const app = runtime({ ExamplePlugin }, async options => {
112
+ calls.push(options);
113
+ return astAsync(options);
114
+ });
115
+ const el = new Element(config);
116
+ await app.init(el);
117
+ assert.equal(calls.length, 1);
118
+ assert.deepEqual(JSON.parse(JSON.stringify(calls[0].executeAST)), config);
119
+ assert.equal(calls[0].sandbox.$this, el);
120
+ assert.equal(calls[0].sandbox.$ExamplePlugin, ExamplePlugin);
121
+ assert.equal(calls[0].node, el);
122
+ assert.ok(el.instance instanceof ExamplePlugin);
123
+ assert.equal(el.instance.host, el);
124
+ assert.equal(el.instance.rendered, true);
125
+ });
126
+
127
+ test('plain keys store data rather than being rewritten into plugin calls', async () => {
128
+ let calls = 0;
129
+ function ExamplePlugin() { calls++; }
130
+ const app = runtime({ ExamplePlugin });
131
+ const el = new Element({
132
+ ExamplePlugin: ['configuration data'],
133
+ '$this.saved': '$ExamplePlugin'
134
+ });
135
+ await app.init(el);
136
+ assert.equal(calls, 0);
137
+ assert.equal(el.saved, ExamplePlugin);
138
+ });
139
+
140
+ test('configuration blocks execute in order without special treatment for $return', async () => {
141
+ const order = [];
142
+ function ExamplePlugin(value) { order.push(value); return value; }
143
+ const app = runtime({ ExamplePlugin });
144
+ const el = new Element([
145
+ { '$this.first': { $ExamplePlugin: ['first'] } },
146
+ { $return: { '$this.second': { $ExamplePlugin: ['$this.first'] } } }
147
+ ]);
148
+ await app.init(el);
149
+ assert.deepEqual(order, ['first', 'first']);
150
+ assert.equal(el.first, 'first');
151
+ assert.equal(el.second, 'first');
152
+ assert.equal(Object.hasOwn(el, 'ExamplePlugin'), false);
153
+ });
154
+
155
+ test('concurrent initialization calls await the same pending setup', async () => {
156
+ let release;
157
+ let entered;
158
+ let calls = 0;
159
+ const gate = new Promise(resolve => { release = resolve; });
160
+ const started = new Promise(resolve => { entered = resolve; });
161
+ const app = runtime({ ExamplePlugin() {} }, async args => {
162
+ calls++;
163
+ entered();
164
+ await gate;
165
+ return astAsync(args);
166
+ });
167
+ const element = new Element('ExamplePlugin');
168
+ const first = app.init(element);
169
+ await started;
170
+ let secondFinished = false;
171
+ const second = app.init(element).then(() => { secondFinished = true; });
172
+ await Promise.resolve();
173
+ await Promise.resolve();
174
+ assert.equal(secondFinished, false);
175
+ assert.equal(calls, 1);
176
+ release();
177
+ await Promise.all([first, second]);
178
+ assert.equal(secondFinished, true);
179
+ await app.init(element);
180
+ assert.equal(calls, 1);
181
+ });
182
+
183
+ test('a plugin can await another initializer before connecting its result', async () => {
184
+ let release;
185
+ let entered;
186
+ let connected = false;
187
+ const gate = new Promise(resolve => { release = resolve; });
188
+ const started = new Promise(resolve => { entered = resolve; });
189
+ const markdown = new Element({ '$this.parser': { $Parser: [] } });
190
+ const CoCreate = { plugins: { init: () => app.init(markdown) } };
191
+ const app = runtime({
192
+ CoCreate,
193
+ async Parser() {
194
+ entered();
195
+ await gate;
196
+ return { ready: true };
197
+ },
198
+ connect() {
199
+ assert.equal(markdown.parser.ready, true);
200
+ connected = true;
201
+ }
202
+ });
203
+ const prism = new Element({ '$CoCreate.plugins.init': ['#markdown'], $connect: [] });
204
+ const first = app.init(markdown);
205
+ await started;
206
+ const second = app.init(prism);
207
+ await Promise.resolve();
208
+ assert.equal(connected, false);
209
+ release();
210
+ await Promise.all([first, second]);
211
+ assert.equal(connected, true);
212
+ });
package/webpack.config.js CHANGED
@@ -1,9 +1,13 @@
1
- const path = require("path");
2
- const MiniCssExtractPlugin = require("mini-css-extract-plugin");
3
- const { EsbuildPlugin } = require("esbuild-loader");
4
- const { FileUploader } = require("@cocreate/webpack");
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import MiniCssExtractPlugin from "mini-css-extract-plugin";
4
+ import { EsbuildPlugin } from "esbuild-loader";
5
+ import webpackPackage from "@cocreate/webpack";
5
6
 
6
- module.exports = async (env, argv) => {
7
+ const { FileUploader } = webpackPackage;
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+
10
+ export default async (env, argv) => {
7
11
  const isProduction = argv && argv.mode === "production";
8
12
  const config = {
9
13
  entry: {
@@ -52,7 +56,7 @@ module.exports = async (env, argv) => {
52
56
  })
53
57
  ],
54
58
  splitChunks: {
55
- pluginsGroups: {
59
+ cacheGroups: {
56
60
  defaultVendors: false
57
61
  }
58
62
  }
File without changes