webim-plugin 1.0.1 → 1.1.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/README.md CHANGED
@@ -15,6 +15,18 @@ npm create webim-plugin my-widget # or the create flow
15
15
 
16
16
  Requires Node 18+.
17
17
 
18
+ > **Note:** on npm ≥ 11.17 the install may print an `allow-scripts` warning
19
+ > about esbuild's postinstall script. It's safe to ignore — esbuild's platform
20
+ > binary is installed via `optionalDependencies`, so the CLI works without the
21
+ > script. To silence it, allow the script explicitly:
22
+ >
23
+ > ```bash
24
+ > npm install -g webim-plugin --allow-scripts=esbuild
25
+ > ```
26
+ >
27
+ > In a project-scoped install (e.g. a scaffolded plugin), add
28
+ > `"allowScripts": { "esbuild": true }` to the project's `package.json` instead.
29
+
18
30
  ## Usage
19
31
 
20
32
  ```bash
@@ -25,6 +37,31 @@ webim-plugin push # build, sign in, merge into builder_plugins
25
37
  webim-plugin dev # watch src/, rebuild + push on every change
26
38
  ```
27
39
 
40
+ ### Converting an existing project
41
+
42
+ ```bash
43
+ cd my-existing-app
44
+ npx webim-plugin wrap # adds webim-plugin.json + an adapter entry + WEBIM_PLUGIN.md
45
+ ```
46
+
47
+ `wrap` never touches existing files. It detects the project (TypeScript?
48
+ React?) and generates a matching adapter entry (`webim.ts/.tsx/.js/.jsx`)
49
+ that builds and renders a placeholder immediately — you replace the
50
+ placeholder with your app. The adapter pattern is Shadow-DOM based, so the
51
+ app's CSS can't restyle the host page and the block can be mounted several
52
+ times on one page.
53
+
54
+ It also writes **`WEBIM_PLUGIN.md`** — the plugin contract and constraints
55
+ written as directives for AI assistants (module shape, build-pipeline facts,
56
+ rendering MUSTs, a conversion checklist). Reference it from your project's
57
+ `AGENTS.md`/`CLAUDE.md` and any AI coding tool can do the conversion work
58
+ correctly. `init` scaffolds include the same file.
59
+
60
+ The bundler inlines everything into the single plugin module: `.css` and
61
+ `.html` imports arrive as strings, images and fonts as `data:` URLs, and
62
+ `.jsx`/`.tsx` compile with the automatic JSX runtime (no `import React`
63
+ needed).
64
+
28
65
  ## Config — `webim-plugin.json`
29
66
 
30
67
  ```json
@@ -21,7 +21,6 @@
21
21
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
22
22
  import { existsSync } from 'node:fs';
23
23
  import path from 'node:path';
24
- import { pathToFileURL } from 'node:url';
25
24
 
26
25
  const CONFIG_FILE = 'webim-plugin.json';
27
26
 
@@ -29,6 +28,9 @@ const HELP = `webim-plugin — Web-IM builder plugin CLI
29
28
 
30
29
  Commands:
31
30
  init [dir] scaffold a plugin project (webim-plugin.json, src/, README)
31
+ wrap [dir] convert an EXISTING project: add config, an adapter entry
32
+ (Shadow-DOM boilerplate, React-aware) and WEBIM_PLUGIN.md —
33
+ the plugin contract written as rules for AI assistants
32
34
  build bundle the entry into dist/plugin.js and validate it
33
35
  push build, then upload to the tenant's builder_plugins setting
34
36
  dev watch the source; rebuild and push on every change
@@ -119,7 +121,10 @@ const TEMPLATE_PACKAGE = (id) => `${JSON.stringify(
119
121
  version: '1.0.0',
120
122
  type: 'module',
121
123
  scripts: { build: 'webim-plugin build', push: 'webim-plugin push', dev: 'webim-plugin dev' },
122
- devDependencies: { 'webim-plugin': '^0.1.0' }
124
+ devDependencies: { 'webim-plugin': '^1.1.0' },
125
+ // npm >= 11.17 blocks dependency install scripts unless allowlisted;
126
+ // esbuild works without its postinstall, this just silences the warning.
127
+ allowScripts: { esbuild: true }
123
128
  },
124
129
  null,
125
130
  2
@@ -145,6 +150,7 @@ async function init(dirArg) {
145
150
  await writeFile(path.join(dir, CONFIG_FILE), TEMPLATE_CONFIG(name));
146
151
  await writeFile(path.join(dir, 'src', 'index.js'), TEMPLATE_PLUGIN(id, name));
147
152
  await writeFile(path.join(dir, 'README.md'), TEMPLATE_README(id, name));
153
+ await writeFile(path.join(dir, 'WEBIM_PLUGIN.md'), TEMPLATE_GUIDE(id, name, 'src/index.js'));
148
154
 
149
155
  if (!existsSync(path.join(dir, 'package.json'))) {
150
156
  await writeFile(path.join(dir, 'package.json'), TEMPLATE_PACKAGE(id));
@@ -154,6 +160,321 @@ async function init(dirArg) {
154
160
  console.log('Next: edit webim-plugin.json (api/tenant), then `webim-plugin build`.');
155
161
  }
156
162
 
163
+ // ---------------------------------------------------------------------------
164
+ // wrap — convert an existing project into a plugin
165
+
166
+ /**
167
+ * The plugin contract written as rules for AI assistants. Dropped into the
168
+ * project by `wrap` (and referenced from AGENTS.md/CLAUDE.md by the user) so
169
+ * any model editing the project builds a valid plugin on the first try.
170
+ */
171
+ const TEMPLATE_GUIDE = (id, name, entry) => `# Web-IM plugin rules — ${name}
172
+
173
+ Directives for AI assistants (and humans) working on the Web-IM builder
174
+ plugin in this project. Follow every MUST. The plugin entry is \`${entry}\`;
175
+ \`npx webim-plugin build\` bundles it to \`dist/plugin.js\` and validates it.
176
+
177
+ ## The contract
178
+
179
+ The entry module's **default export** is the whole plugin:
180
+
181
+ \`\`\`js
182
+ export default {
183
+ id: '${id}', // kebab-case slug, /^[a-z0-9-]+$/
184
+ name: '${name}',
185
+ version: '1.0.0',
186
+ blocks: [{
187
+ id: 'main', // kebab-case slug
188
+ label: '${name}', // shown in the builder Toolbox/Inspector
189
+ props: { /* default prop values for a freshly dropped block */ },
190
+ settings: [ /* typed Inspector fields, see below */ ],
191
+ mount(el, props, ctx) {}, // REQUIRED — render into el
192
+ update(el, props, ctx) {}, // optional — props changed; omit to get unmount+mount
193
+ unmount(el) {} // optional — cleanup
194
+ }]
195
+ };
196
+ \`\`\`
197
+
198
+ \`ctx\` is \`{ tenant, apiUrl, locale, settings }\`.
199
+
200
+ \`settings\` fields are \`{ name, label, type }\` with type one of
201
+ \`text | number | switch | select | color | image\`; \`select\` also takes
202
+ \`options: [{ value, label }]\` (or plain strings). Every setting's \`name\`
203
+ must have a default in \`props\`.
204
+
205
+ ## Build pipeline facts
206
+
207
+ - Everything must land in ONE ES module — \`push\` uploads only the built JS.
208
+ - \`import\` of \`.css\`/\`.html\` files yields the file content as a STRING
209
+ (inject a \`<style>\` tag / parse markup yourself at mount time).
210
+ - Images and fonts (\`.svg .png .jpg .jpeg .gif .webp .woff .woff2\`) import
211
+ as \`data:\` URLs.
212
+ - After bundling, the CLI IMPORTS the bundle in Node.js to validate it.
213
+ Module scope therefore MUST NOT touch \`document\`, \`window\`,
214
+ \`localStorage\`, or run any DOM code. All DOM work goes inside \`mount()\`.
215
+ Guard any standalone auto-init with \`typeof document !== 'undefined'\`.
216
+
217
+ ## Rendering rules (MUST)
218
+
219
+ 1. Render inside a Shadow DOM:
220
+ \`const shadow = el.shadowRoot ?? el.attachShadow({ mode: 'open' })\` —
221
+ the host clears \`el.innerHTML\` between remounts but an attached shadow
222
+ root SURVIVES, so always reuse it and reset with \`shadow.innerHTML = ''\`.
223
+ 2. Never style or mutate \`document\`, \`document.body\`, or
224
+ \`document.documentElement\` (no classes, no attributes, no global CSS).
225
+ The single exception: webfont \`<link>\` tags may be appended to
226
+ \`document.head\` (deduplicate first) — \`@font-face\` is inert inside a
227
+ shadow root.
228
+ 3. If reusing an app's page-level stylesheet, rescope it to a wrapper div
229
+ inside the shadow: \`body\`/\`html\`/\`:root\` selectors → the wrapper class,
230
+ \`position: fixed\` → \`absolute\`, \`100vw/100vh\` → \`100%\`. Give the
231
+ wrapper \`position: relative; overflow: hidden\`.
232
+ 4. The same block can be mounted MULTIPLE times on one page: no module-level
233
+ singletons or mutable shared state — key per-instance state by \`el\`
234
+ (e.g. a \`WeakMap\`). Element IDs inside the shadow root are fine.
235
+ 5. \`unmount()\` must undo everything attached outside the shadow root:
236
+ \`document\`/\`window\` listeners, timers, observers, animation-frame
237
+ loops, open sockets.
238
+ 6. \`update()\` runs on every Inspector prop edit. When in-place diffing is
239
+ not trivial, destroy + remount — correctness beats cleverness.
240
+ 7. Network calls: use plain \`fetch\`; never assume same-origin cookies.
241
+ Degrade gracefully — a failed request must not blank the block.
242
+
243
+ ## Converting an existing app (checklist)
244
+
245
+ - [ ] Write the adapter entry (\`${entry}\`) — it maps builder props to the
246
+ app's options and boots the app inside the shadow root.
247
+ - [ ] Remove the app's assumption that it owns the page: inject its markup
248
+ into the wrapper, query elements from the shadow root (note:
249
+ \`getElementById\` exists on ShadowRoot), move body-level classes and
250
+ theme attributes onto the wrapper.
251
+ - [ ] For outside-click handlers on \`document\`, use
252
+ \`e.composedPath()[0]\` — \`e.target\` retargets to the host element.
253
+ - [ ] Expose the app's config as \`settings\` + \`props\` defaults; pass them
254
+ explicitly so the host page's URL params never leak in.
255
+ - [ ] \`npx webim-plugin build\` passes, then test mount/update/unmount and
256
+ two instances on one page.
257
+
258
+ ## Ship
259
+
260
+ \`\`\`bash
261
+ npx webim-plugin build # bundle + validate -> dist/plugin.js
262
+ npx webim-plugin push # upload to the tenant in webim-plugin.json
263
+ npx webim-plugin dev # watch + rebuild + push
264
+ \`\`\`
265
+
266
+ Auth for push: \`WEBIM_TOKEN\`, or \`WEBIM_EMAIL\` + \`WEBIM_PASSWORD\`.
267
+ `;
268
+
269
+ /** Vanilla adapter skeleton — builds and renders a placeholder as generated. */
270
+ const TEMPLATE_WRAP_VANILLA = (id, name, ts) => {
271
+ const el = ts ? 'el: HTMLElement' : 'el';
272
+ const props = ts ? 'props: Record<string, any>' : 'props';
273
+ const ctx = ts ? 'ctx: { tenant?: string; apiUrl?: string; locale?: string }' : 'ctx';
274
+
275
+ return `// ${name} — Web-IM builder plugin adapter (entry for \`webim-plugin build\`).
276
+ // Read WEBIM_PLUGIN.md before editing: it is the contract and the rules.
277
+ //
278
+ // CSS imports arrive as strings under the webim-plugin CLI, e.g.:
279
+ // import cssText from './styles.css';
280
+
281
+ function mountBlock(${el}, ${props}, ${ctx}) {
282
+ // The host clears el.innerHTML between remounts but a shadow root survives.
283
+ const shadow = el.shadowRoot ?? el.attachShadow({ mode: 'open' });
284
+ shadow.innerHTML = '';
285
+
286
+ const style = document.createElement('style');
287
+ style.textContent = \`
288
+ :host { display: block; }
289
+ .root { position: relative; overflow: hidden; font-family: system-ui, sans-serif; }
290
+ \`; // TODO: append your (rescoped) stylesheet string here
291
+
292
+ const root = document.createElement('div');
293
+ root.className = 'root';
294
+ // TODO: render your app into \`root\` (markup, components, listeners).
295
+ root.textContent = \`${name} — replace mountBlock() with your app\`;
296
+
297
+ shadow.append(style, root);
298
+ }
299
+
300
+ export default {
301
+ id: '${id}',
302
+ name: '${name}',
303
+ version: '1.0.0',
304
+ blocks: [
305
+ {
306
+ id: 'main',
307
+ label: '${name}',
308
+ // TODO: defaults for every settings field below.
309
+ props: { title: '${name}' },
310
+ // Inspector fields: text | number | switch | select | color | image.
311
+ settings: [{ name: 'title', label: 'Title', type: 'text' }],
312
+ mount(${el}, ${props}, ${ctx}) {
313
+ mountBlock(el, props, ctx);
314
+ },
315
+ update(${el}, ${props}, ${ctx}) {
316
+ // Props changed. Remount is the simple correct default.
317
+ mountBlock(el, props, ctx);
318
+ },
319
+ unmount(${el}) {
320
+ // TODO: remove document/window listeners, timers, observers.
321
+ if (el.shadowRoot) el.shadowRoot.innerHTML = '';
322
+ }
323
+ }
324
+ ]
325
+ };
326
+ `;
327
+ };
328
+
329
+ /** React adapter skeleton — builds as generated; wire the real component in. */
330
+ const TEMPLATE_WRAP_REACT = (id, name, ts) => {
331
+ const el = ts ? 'el: HTMLElement' : 'el';
332
+ const props = ts ? 'props: Record<string, any>' : 'props';
333
+ const ctx = ts ? 'ctx: { tenant?: string; apiUrl?: string; locale?: string }' : 'ctx';
334
+ const rootsType = ts ? '<HTMLElement, Root>' : '';
335
+
336
+ return `// ${name} — Web-IM builder plugin adapter (entry for \`webim-plugin build\`).
337
+ // Read WEBIM_PLUGIN.md before editing: it is the contract and the rules.
338
+ import { createRoot${ts ? ', type Root' : ''} } from 'react-dom/client';
339
+ // TODO: import your component and render it in mountBlock, e.g.:
340
+ // import App from './App';
341
+ //
342
+ // CSS imports arrive as strings under the webim-plugin CLI, e.g.:
343
+ // import cssText from './styles.css';
344
+
345
+ const roots = new WeakMap${rootsType}();
346
+
347
+ function mountBlock(${el}, ${props}, ${ctx}) {
348
+ // The host clears el.innerHTML between remounts but a shadow root survives.
349
+ const shadow = el.shadowRoot ?? el.attachShadow({ mode: 'open' });
350
+ shadow.innerHTML = '';
351
+
352
+ const style = document.createElement('style');
353
+ style.textContent = ':host { display: block; }'; // TODO: + your rescoped stylesheet string
354
+
355
+ const container = document.createElement('div');
356
+ shadow.append(style, container);
357
+
358
+ const root = createRoot(container);
359
+ roots.set(el, root);
360
+ // TODO: replace the placeholder with your component, e.g. <App {...props} />
361
+ root.render(<div style={{ fontFamily: 'system-ui' }}>${name} — wire your component into mountBlock()</div>);
362
+ }
363
+
364
+ export default {
365
+ id: '${id}',
366
+ name: '${name}',
367
+ version: '1.0.0',
368
+ blocks: [
369
+ {
370
+ id: 'main',
371
+ label: '${name}',
372
+ // TODO: defaults for every settings field below.
373
+ props: { title: '${name}' },
374
+ // Inspector fields: text | number | switch | select | color | image.
375
+ settings: [{ name: 'title', label: 'Title', type: 'text' }],
376
+ mount(${el}, ${props}, ${ctx}) {
377
+ mountBlock(el, props, ctx);
378
+ },
379
+ update(${el}, ${props}, ${ctx}) {
380
+ mountBlock(el, props, ctx);
381
+ },
382
+ unmount(${el}) {
383
+ roots.get(el)?.unmount();
384
+ roots.delete(el);
385
+ if (el.shadowRoot) el.shadowRoot.innerHTML = '';
386
+ }
387
+ }
388
+ ]
389
+ };
390
+ `;
391
+ };
392
+
393
+ const TEMPLATE_MODULE_DECLS = `// The webim-plugin CLI bundles .css and .html imports as plain strings
394
+ // (esbuild \`text\` loader); images and fonts import as data: URLs.
395
+ declare module '*.css' {
396
+ const text: string;
397
+ export default text;
398
+ }
399
+ declare module '*.html' {
400
+ const text: string;
401
+ export default text;
402
+ }
403
+ `;
404
+
405
+ /**
406
+ * Converts an existing project in place: writes webim-plugin.json, an adapter
407
+ * entry matched to the project (TS? React?) and WEBIM_PLUGIN.md. Only ever
408
+ * CREATES files — an existing file with a target name aborts or is skipped.
409
+ */
410
+ async function wrap(dirArg) {
411
+ const dir = path.resolve(dirArg ?? '.');
412
+
413
+ if (existsSync(path.join(dir, CONFIG_FILE))) {
414
+ throw new Error(`${CONFIG_FILE} already exists in ${dir} — this project is already wired up`);
415
+ }
416
+
417
+ let pkg = {};
418
+
419
+ try {
420
+ pkg = JSON.parse(await readFile(path.join(dir, 'package.json'), 'utf8'));
421
+ } catch {
422
+ // no package.json is fine — fall back to the directory name
423
+ }
424
+
425
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
426
+ const isReact = 'react' in deps;
427
+ const isTS = existsSync(path.join(dir, 'tsconfig.json')) || 'typescript' in deps;
428
+ const ext = isReact ? (isTS ? 'tsx' : 'jsx') : isTS ? 'ts' : 'js';
429
+
430
+ const id = String(pkg.name ?? path.basename(dir))
431
+ .toLowerCase()
432
+ .replace(/^@[^/]+\//, '')
433
+ .replace(/[^a-z0-9]+/g, '-')
434
+ .replace(/(^-|-$)/g, '') || 'my-plugin';
435
+ const name = id
436
+ .split('-')
437
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
438
+ .join(' ');
439
+
440
+ const srcDir = existsSync(path.join(dir, 'src')) ? 'src' : '.';
441
+ const entry = `${srcDir === '.' ? '' : 'src/'}webim.${ext}`;
442
+ const entryFile = path.join(dir, entry);
443
+
444
+ if (existsSync(entryFile)) throw new Error(`${entry} already exists in ${dir}`);
445
+
446
+ await mkdir(path.dirname(entryFile), { recursive: true });
447
+ await writeFile(path.join(dir, CONFIG_FILE), `${JSON.stringify(
448
+ { entry, out: 'dist/plugin.js', api: 'http://localhost:4100/api', tenant: 'root' },
449
+ null,
450
+ 2
451
+ )}\n`);
452
+ await writeFile(entryFile, (isReact ? TEMPLATE_WRAP_REACT : TEMPLATE_WRAP_VANILLA)(id, name, isTS));
453
+
454
+ const created = [CONFIG_FILE, entry];
455
+
456
+ if (isTS) {
457
+ const decls = path.join(dir, srcDir, 'webim-modules.d.ts');
458
+
459
+ if (!existsSync(decls)) {
460
+ await writeFile(decls, TEMPLATE_MODULE_DECLS);
461
+ created.push(path.relative(dir, decls));
462
+ }
463
+ }
464
+
465
+ if (!existsSync(path.join(dir, 'WEBIM_PLUGIN.md'))) {
466
+ await writeFile(path.join(dir, 'WEBIM_PLUGIN.md'), TEMPLATE_GUIDE(id, name, entry));
467
+ created.push('WEBIM_PLUGIN.md');
468
+ }
469
+
470
+ console.log(`Wrapped "${id}" (${isReact ? 'React' : 'vanilla'}${isTS ? ' + TypeScript' : ''}) — created ${created.join(', ')}`);
471
+ console.log(`Next:
472
+ 1. Wire your app into ${entry} — WEBIM_PLUGIN.md has the contract and rules.
473
+ Working with an AI assistant? Point it at WEBIM_PLUGIN.md (reference it
474
+ from AGENTS.md / CLAUDE.md so it is picked up automatically).
475
+ 2. Edit ${CONFIG_FILE} (api/tenant), then \`npx webim-plugin build\`.`);
476
+ }
477
+
157
478
  // ---------------------------------------------------------------------------
158
479
  // build + validate
159
480
 
@@ -172,6 +493,22 @@ async function loadConfig() {
172
493
  };
173
494
  }
174
495
 
496
+ // Everything must land in ONE JS module — the only thing push uploads. CSS
497
+ // and HTML come in as strings (inject a <style> / parse markup at mount),
498
+ // images and fonts as data: URLs.
499
+ const LOADERS = {
500
+ '.css': 'text',
501
+ '.html': 'text',
502
+ '.svg': 'dataurl',
503
+ '.png': 'dataurl',
504
+ '.jpg': 'dataurl',
505
+ '.jpeg': 'dataurl',
506
+ '.gif': 'dataurl',
507
+ '.webp': 'dataurl',
508
+ '.woff': 'dataurl',
509
+ '.woff2': 'dataurl'
510
+ };
511
+
175
512
  async function bundle(config) {
176
513
  const esbuild = await import('esbuild');
177
514
  const result = await esbuild.build({
@@ -182,7 +519,11 @@ async function bundle(config) {
182
519
  platform: 'browser',
183
520
  target: 'es2020',
184
521
  minify: false,
185
- logLevel: 'silent'
522
+ logLevel: 'silent',
523
+ // Modern JSX runtime (React 17+/Vite default): .jsx/.tsx files work
524
+ // without a manual `import React` in every file.
525
+ jsx: 'automatic',
526
+ loader: LOADERS
186
527
  });
187
528
 
188
529
  if (result.errors.length) throw new Error(result.errors.map((e) => e.text).join('\n'));
@@ -230,9 +571,13 @@ function validatePlugin(plugin) {
230
571
  async function build() {
231
572
  const config = await loadConfig();
232
573
  const out = await bundle(config);
233
- // Import the BUILT file: what ships is what gets validated. Module-level
234
- // DOM access would fail right here — keep DOM work inside mount().
235
- const mod = await import(`${pathToFileURL(path.resolve(out)).href}?v=${Date.now()}`);
574
+ // Import the BUILT code: what ships is what gets validated. Module-level
575
+ // DOM access would fail right here — keep DOM work inside mount(). A data:
576
+ // URL instead of the file path so Node always parses it as ESM — a file
577
+ // import would inherit the project's package.json "type", and a project
578
+ // without "type": "module" breaks on the bundle's export statement.
579
+ const code = await readFile(path.resolve(out), 'utf8');
580
+ const mod = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`);
236
581
  const manifest = validatePlugin(mod?.default);
237
582
 
238
583
  console.log(`Built ${out}`);
@@ -336,6 +681,8 @@ async function dev() {
336
681
  platform: 'browser',
337
682
  target: 'es2020',
338
683
  logLevel: 'silent',
684
+ jsx: 'automatic',
685
+ loader: LOADERS,
339
686
  plugins: [
340
687
  {
341
688
  name: 'webim-push',
@@ -372,6 +719,7 @@ try {
372
719
 
373
720
  console.log(pkg.version);
374
721
  } else if (command === 'init') await init(argument);
722
+ else if (command === 'wrap') await wrap(argument);
375
723
  else if (command === 'build') await build();
376
724
  else if (command === 'push') await push();
377
725
  else if (command === 'dev') await dev();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webim-plugin",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "CLI for Web-IM builder plugins — scaffold, bundle TS/JSX into the single-module standard, validate, and push to a tenant",
5
5
  "license": "MIT",
6
6
  "author": "Orhan POLAT",