webim-plugin 1.0.1 → 1.1.1
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 +37 -0
- package/bin/webim-plugin.js +386 -9
- package/package.json +1 -1
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
|
package/bin/webim-plugin.js
CHANGED
|
@@ -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,13 +28,16 @@ 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
|
|
35
37
|
|
|
36
38
|
Config (webim-plugin.json):
|
|
37
39
|
{ "entry": "src/index.js", "out": "dist/plugin.js",
|
|
38
|
-
"api": "
|
|
40
|
+
"api": "https://webim.app/api", "tenant": "root" }
|
|
39
41
|
|
|
40
42
|
Auth (push/dev): WEBIM_TOKEN, or WEBIM_EMAIL + WEBIM_PASSWORD.`;
|
|
41
43
|
|
|
@@ -43,7 +45,7 @@ Auth (push/dev): WEBIM_TOKEN, or WEBIM_EMAIL + WEBIM_PASSWORD.`;
|
|
|
43
45
|
// scaffolding
|
|
44
46
|
|
|
45
47
|
const TEMPLATE_CONFIG = (name) => `${JSON.stringify(
|
|
46
|
-
{ entry: 'src/index.js', out: 'dist/plugin.js', api: '
|
|
48
|
+
{ entry: 'src/index.js', out: 'dist/plugin.js', api: 'https://webim.app/api', tenant: 'root' },
|
|
47
49
|
null,
|
|
48
50
|
2
|
|
49
51
|
)}\n`;
|
|
@@ -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': '^
|
|
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,324 @@ 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). In \`.css\`
|
|
210
|
+
imports the CLI rewrites \`:root\` to \`:host\` — inside a shadow root
|
|
211
|
+
\`:root\` matches nothing, \`:host\` is its exact analogue.
|
|
212
|
+
- Images and fonts (\`.svg .png .jpg .jpeg .gif .webp .woff .woff2\`) import
|
|
213
|
+
as \`data:\` URLs.
|
|
214
|
+
- After bundling, the CLI IMPORTS the bundle in Node.js to validate it.
|
|
215
|
+
Module scope therefore MUST NOT touch \`document\`, \`window\`,
|
|
216
|
+
\`localStorage\`, or run any DOM code. All DOM work goes inside \`mount()\`.
|
|
217
|
+
Guard any standalone auto-init with \`typeof document !== 'undefined'\`.
|
|
218
|
+
|
|
219
|
+
## Rendering rules (MUST)
|
|
220
|
+
|
|
221
|
+
1. Render inside a Shadow DOM:
|
|
222
|
+
\`const shadow = el.shadowRoot ?? el.attachShadow({ mode: 'open' })\` —
|
|
223
|
+
the host clears \`el.innerHTML\` between remounts but an attached shadow
|
|
224
|
+
root SURVIVES, so always reuse it and reset with \`shadow.innerHTML = ''\`.
|
|
225
|
+
2. Never style or mutate \`document\`, \`document.body\`, or
|
|
226
|
+
\`document.documentElement\` (no classes, no attributes, no global CSS).
|
|
227
|
+
The single exception: webfont \`<link>\` tags may be appended to
|
|
228
|
+
\`document.head\` (deduplicate first) — \`@font-face\` is inert inside a
|
|
229
|
+
shadow root.
|
|
230
|
+
3. If reusing an app's page-level stylesheet, rescope it to a wrapper div
|
|
231
|
+
inside the shadow: \`body\`/\`html\` selectors → the wrapper class
|
|
232
|
+
(\`:root\` is rewritten to \`:host\` by the build automatically),
|
|
233
|
+
\`position: fixed\` → \`absolute\`, \`100vw/100vh\` → \`100%\`. Give the
|
|
234
|
+
wrapper \`position: relative; overflow: hidden\`.
|
|
235
|
+
4. The same block can be mounted MULTIPLE times on one page: no module-level
|
|
236
|
+
singletons or mutable shared state — key per-instance state by \`el\`
|
|
237
|
+
(e.g. a \`WeakMap\`). Element IDs inside the shadow root are fine.
|
|
238
|
+
5. \`unmount()\` must undo everything attached outside the shadow root:
|
|
239
|
+
\`document\`/\`window\` listeners, timers, observers, animation-frame
|
|
240
|
+
loops, open sockets.
|
|
241
|
+
6. \`update()\` runs on every Inspector prop edit. When in-place diffing is
|
|
242
|
+
not trivial, destroy + remount — correctness beats cleverness.
|
|
243
|
+
7. Network calls: use plain \`fetch\`; never assume same-origin cookies.
|
|
244
|
+
Degrade gracefully — a failed request must not blank the block.
|
|
245
|
+
|
|
246
|
+
## Converting an existing app (checklist)
|
|
247
|
+
|
|
248
|
+
- [ ] Write the adapter entry (\`${entry}\`) — it maps builder props to the
|
|
249
|
+
app's options and boots the app inside the shadow root.
|
|
250
|
+
- [ ] Remove the app's assumption that it owns the page: inject its markup
|
|
251
|
+
into the wrapper, query elements from the shadow root (note:
|
|
252
|
+
\`getElementById\` exists on ShadowRoot), move body-level classes and
|
|
253
|
+
theme attributes onto the wrapper.
|
|
254
|
+
- [ ] For outside-click handlers on \`document\`, use
|
|
255
|
+
\`e.composedPath()[0]\` — \`e.target\` retargets to the host element.
|
|
256
|
+
- [ ] Expose the app's config as \`settings\` + \`props\` defaults; pass them
|
|
257
|
+
explicitly so the host page's URL params never leak in.
|
|
258
|
+
- [ ] \`npx webim-plugin build\` passes, then test mount/update/unmount and
|
|
259
|
+
two instances on one page.
|
|
260
|
+
|
|
261
|
+
## Ship
|
|
262
|
+
|
|
263
|
+
\`\`\`bash
|
|
264
|
+
npx webim-plugin build # bundle + validate -> dist/plugin.js
|
|
265
|
+
npx webim-plugin push # upload to the tenant in webim-plugin.json
|
|
266
|
+
npx webim-plugin dev # watch + rebuild + push
|
|
267
|
+
\`\`\`
|
|
268
|
+
|
|
269
|
+
Auth for push: \`WEBIM_TOKEN\`, or \`WEBIM_EMAIL\` + \`WEBIM_PASSWORD\`.
|
|
270
|
+
`;
|
|
271
|
+
|
|
272
|
+
/** Vanilla adapter skeleton — builds and renders a placeholder as generated. */
|
|
273
|
+
const TEMPLATE_WRAP_VANILLA = (id, name, ts) => {
|
|
274
|
+
const el = ts ? 'el: HTMLElement' : 'el';
|
|
275
|
+
const props = ts ? 'props: Record<string, any>' : 'props';
|
|
276
|
+
const ctx = ts ? 'ctx: { tenant?: string; apiUrl?: string; locale?: string }' : 'ctx';
|
|
277
|
+
|
|
278
|
+
return `// ${name} — Web-IM builder plugin adapter (entry for \`webim-plugin build\`).
|
|
279
|
+
// Read WEBIM_PLUGIN.md before editing: it is the contract and the rules.
|
|
280
|
+
//
|
|
281
|
+
// CSS imports arrive as strings under the webim-plugin CLI, e.g.:
|
|
282
|
+
// import cssText from './styles.css';
|
|
283
|
+
|
|
284
|
+
function mountBlock(${el}, ${props}, ${ctx}) {
|
|
285
|
+
// The host clears el.innerHTML between remounts but a shadow root survives.
|
|
286
|
+
const shadow = el.shadowRoot ?? el.attachShadow({ mode: 'open' });
|
|
287
|
+
shadow.innerHTML = '';
|
|
288
|
+
|
|
289
|
+
const style = document.createElement('style');
|
|
290
|
+
style.textContent = \`
|
|
291
|
+
:host { display: block; }
|
|
292
|
+
.root { position: relative; overflow: hidden; font-family: system-ui, sans-serif; }
|
|
293
|
+
\`; // TODO: append your (rescoped) stylesheet string here
|
|
294
|
+
|
|
295
|
+
const root = document.createElement('div');
|
|
296
|
+
root.className = 'root';
|
|
297
|
+
// TODO: render your app into \`root\` (markup, components, listeners).
|
|
298
|
+
root.textContent = \`${name} — replace mountBlock() with your app\`;
|
|
299
|
+
|
|
300
|
+
shadow.append(style, root);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export default {
|
|
304
|
+
id: '${id}',
|
|
305
|
+
name: '${name}',
|
|
306
|
+
version: '1.0.0',
|
|
307
|
+
blocks: [
|
|
308
|
+
{
|
|
309
|
+
id: 'main',
|
|
310
|
+
label: '${name}',
|
|
311
|
+
// TODO: defaults for every settings field below.
|
|
312
|
+
props: { title: '${name}' },
|
|
313
|
+
// Inspector fields: text | number | switch | select | color | image.
|
|
314
|
+
settings: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
315
|
+
mount(${el}, ${props}, ${ctx}) {
|
|
316
|
+
mountBlock(el, props, ctx);
|
|
317
|
+
},
|
|
318
|
+
update(${el}, ${props}, ${ctx}) {
|
|
319
|
+
// Props changed. Remount is the simple correct default.
|
|
320
|
+
mountBlock(el, props, ctx);
|
|
321
|
+
},
|
|
322
|
+
unmount(${el}) {
|
|
323
|
+
// TODO: remove document/window listeners, timers, observers.
|
|
324
|
+
if (el.shadowRoot) el.shadowRoot.innerHTML = '';
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
]
|
|
328
|
+
};
|
|
329
|
+
`;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/** React adapter skeleton — builds as generated; wire the real component in. */
|
|
333
|
+
const TEMPLATE_WRAP_REACT = (id, name, ts) => {
|
|
334
|
+
const el = ts ? 'el: HTMLElement' : 'el';
|
|
335
|
+
const props = ts ? 'props: Record<string, any>' : 'props';
|
|
336
|
+
const ctx = ts ? 'ctx: { tenant?: string; apiUrl?: string; locale?: string }' : 'ctx';
|
|
337
|
+
const rootsType = ts ? '<HTMLElement, Root>' : '';
|
|
338
|
+
|
|
339
|
+
return `// ${name} — Web-IM builder plugin adapter (entry for \`webim-plugin build\`).
|
|
340
|
+
// Read WEBIM_PLUGIN.md before editing: it is the contract and the rules.
|
|
341
|
+
import { createRoot${ts ? ', type Root' : ''} } from 'react-dom/client';
|
|
342
|
+
// TODO: import your component and render it in mountBlock, e.g.:
|
|
343
|
+
// import App from './App';
|
|
344
|
+
//
|
|
345
|
+
// CSS imports arrive as strings under the webim-plugin CLI, e.g.:
|
|
346
|
+
// import cssText from './styles.css';
|
|
347
|
+
|
|
348
|
+
const roots = new WeakMap${rootsType}();
|
|
349
|
+
|
|
350
|
+
function mountBlock(${el}, ${props}, ${ctx}) {
|
|
351
|
+
// The host clears el.innerHTML between remounts but a shadow root survives.
|
|
352
|
+
const shadow = el.shadowRoot ?? el.attachShadow({ mode: 'open' });
|
|
353
|
+
shadow.innerHTML = '';
|
|
354
|
+
|
|
355
|
+
const style = document.createElement('style');
|
|
356
|
+
style.textContent = ':host { display: block; }'; // TODO: + your rescoped stylesheet string
|
|
357
|
+
|
|
358
|
+
const container = document.createElement('div');
|
|
359
|
+
shadow.append(style, container);
|
|
360
|
+
|
|
361
|
+
const root = createRoot(container);
|
|
362
|
+
roots.set(el, root);
|
|
363
|
+
// TODO: replace the placeholder with your component, e.g. <App {...props} />
|
|
364
|
+
root.render(<div style={{ fontFamily: 'system-ui' }}>${name} — wire your component into mountBlock()</div>);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export default {
|
|
368
|
+
id: '${id}',
|
|
369
|
+
name: '${name}',
|
|
370
|
+
version: '1.0.0',
|
|
371
|
+
blocks: [
|
|
372
|
+
{
|
|
373
|
+
id: 'main',
|
|
374
|
+
label: '${name}',
|
|
375
|
+
// TODO: defaults for every settings field below.
|
|
376
|
+
props: { title: '${name}' },
|
|
377
|
+
// Inspector fields: text | number | switch | select | color | image.
|
|
378
|
+
settings: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
379
|
+
mount(${el}, ${props}, ${ctx}) {
|
|
380
|
+
mountBlock(el, props, ctx);
|
|
381
|
+
},
|
|
382
|
+
update(${el}, ${props}, ${ctx}) {
|
|
383
|
+
mountBlock(el, props, ctx);
|
|
384
|
+
},
|
|
385
|
+
unmount(${el}) {
|
|
386
|
+
roots.get(el)?.unmount();
|
|
387
|
+
roots.delete(el);
|
|
388
|
+
if (el.shadowRoot) el.shadowRoot.innerHTML = '';
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
]
|
|
392
|
+
};
|
|
393
|
+
`;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const TEMPLATE_MODULE_DECLS = `// The webim-plugin CLI bundles .css and .html imports as plain strings
|
|
397
|
+
// (esbuild \`text\` loader); images and fonts import as data: URLs.
|
|
398
|
+
declare module '*.css' {
|
|
399
|
+
const text: string;
|
|
400
|
+
export default text;
|
|
401
|
+
}
|
|
402
|
+
declare module '*.html' {
|
|
403
|
+
const text: string;
|
|
404
|
+
export default text;
|
|
405
|
+
}
|
|
406
|
+
`;
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Converts an existing project in place: writes webim-plugin.json, an adapter
|
|
410
|
+
* entry matched to the project (TS? React?) and WEBIM_PLUGIN.md. Only ever
|
|
411
|
+
* CREATES files — an existing file with a target name aborts or is skipped.
|
|
412
|
+
*/
|
|
413
|
+
async function wrap(dirArg) {
|
|
414
|
+
const dir = path.resolve(dirArg ?? '.');
|
|
415
|
+
|
|
416
|
+
if (existsSync(path.join(dir, CONFIG_FILE))) {
|
|
417
|
+
throw new Error(`${CONFIG_FILE} already exists in ${dir} — this project is already wired up`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let pkg = {};
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
pkg = JSON.parse(await readFile(path.join(dir, 'package.json'), 'utf8'));
|
|
424
|
+
} catch {
|
|
425
|
+
// no package.json is fine — fall back to the directory name
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
429
|
+
const isReact = 'react' in deps;
|
|
430
|
+
const isTS = existsSync(path.join(dir, 'tsconfig.json')) || 'typescript' in deps;
|
|
431
|
+
const ext = isReact ? (isTS ? 'tsx' : 'jsx') : isTS ? 'ts' : 'js';
|
|
432
|
+
|
|
433
|
+
const id = String(pkg.name ?? path.basename(dir))
|
|
434
|
+
.toLowerCase()
|
|
435
|
+
.replace(/^@[^/]+\//, '')
|
|
436
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
437
|
+
.replace(/(^-|-$)/g, '') || 'my-plugin';
|
|
438
|
+
const name = id
|
|
439
|
+
.split('-')
|
|
440
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
441
|
+
.join(' ');
|
|
442
|
+
|
|
443
|
+
const srcDir = existsSync(path.join(dir, 'src')) ? 'src' : '.';
|
|
444
|
+
const entry = `${srcDir === '.' ? '' : 'src/'}webim.${ext}`;
|
|
445
|
+
const entryFile = path.join(dir, entry);
|
|
446
|
+
|
|
447
|
+
if (existsSync(entryFile)) throw new Error(`${entry} already exists in ${dir}`);
|
|
448
|
+
|
|
449
|
+
await mkdir(path.dirname(entryFile), { recursive: true });
|
|
450
|
+
await writeFile(path.join(dir, CONFIG_FILE), `${JSON.stringify(
|
|
451
|
+
{ entry, out: 'dist/plugin.js', api: 'https://webim.app/api', tenant: 'root' },
|
|
452
|
+
null,
|
|
453
|
+
2
|
|
454
|
+
)}\n`);
|
|
455
|
+
await writeFile(entryFile, (isReact ? TEMPLATE_WRAP_REACT : TEMPLATE_WRAP_VANILLA)(id, name, isTS));
|
|
456
|
+
|
|
457
|
+
const created = [CONFIG_FILE, entry];
|
|
458
|
+
|
|
459
|
+
if (isTS) {
|
|
460
|
+
const decls = path.join(dir, srcDir, 'webim-modules.d.ts');
|
|
461
|
+
|
|
462
|
+
if (!existsSync(decls)) {
|
|
463
|
+
await writeFile(decls, TEMPLATE_MODULE_DECLS);
|
|
464
|
+
created.push(path.relative(dir, decls));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (!existsSync(path.join(dir, 'WEBIM_PLUGIN.md'))) {
|
|
469
|
+
await writeFile(path.join(dir, 'WEBIM_PLUGIN.md'), TEMPLATE_GUIDE(id, name, entry));
|
|
470
|
+
created.push('WEBIM_PLUGIN.md');
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
console.log(`Wrapped "${id}" (${isReact ? 'React' : 'vanilla'}${isTS ? ' + TypeScript' : ''}) — created ${created.join(', ')}`);
|
|
474
|
+
console.log(`Next:
|
|
475
|
+
1. Wire your app into ${entry} — WEBIM_PLUGIN.md has the contract and rules.
|
|
476
|
+
Working with an AI assistant? Point it at WEBIM_PLUGIN.md (reference it
|
|
477
|
+
from AGENTS.md / CLAUDE.md so it is picked up automatically).
|
|
478
|
+
2. Edit ${CONFIG_FILE} (api/tenant), then \`npx webim-plugin build\`.`);
|
|
479
|
+
}
|
|
480
|
+
|
|
157
481
|
// ---------------------------------------------------------------------------
|
|
158
482
|
// build + validate
|
|
159
483
|
|
|
@@ -167,11 +491,51 @@ async function loadConfig() {
|
|
|
167
491
|
return {
|
|
168
492
|
entry: config.entry ?? 'src/index.js',
|
|
169
493
|
out: config.out ?? 'dist/plugin.js',
|
|
170
|
-
api: config.api ?? '
|
|
494
|
+
api: config.api ?? 'https://webim.app/api',
|
|
171
495
|
tenant: config.tenant ?? ''
|
|
172
496
|
};
|
|
173
497
|
}
|
|
174
498
|
|
|
499
|
+
// Everything must land in ONE JS module — the only thing push uploads. CSS
|
|
500
|
+
// and HTML come in as strings (inject a <style> / parse markup at mount),
|
|
501
|
+
// images and fonts as data: URLs.
|
|
502
|
+
const LOADERS = {
|
|
503
|
+
'.html': 'text',
|
|
504
|
+
'.svg': 'dataurl',
|
|
505
|
+
'.png': 'dataurl',
|
|
506
|
+
'.jpg': 'dataurl',
|
|
507
|
+
'.jpeg': 'dataurl',
|
|
508
|
+
'.gif': 'dataurl',
|
|
509
|
+
'.webp': 'dataurl',
|
|
510
|
+
'.woff': 'dataurl',
|
|
511
|
+
'.woff2': 'dataurl'
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
// .css imports are strings too, but rescoped first: plugins render inside a
|
|
515
|
+
// Shadow DOM, where `:root` matches nothing — its analogue is `:host`, so the
|
|
516
|
+
// rewrite is always safe (same specificity, same "tree root" meaning).
|
|
517
|
+
// `html`/`body` selectors CAN'T be rewritten automatically — the wrapper
|
|
518
|
+
// class they should target is the plugin's own choice — so they only warn.
|
|
519
|
+
const warnedCssFiles = new Set();
|
|
520
|
+
const cssRescopePlugin = {
|
|
521
|
+
name: 'css-rescope',
|
|
522
|
+
setup(build) {
|
|
523
|
+
build.onLoad({ filter: /\.css$/ }, async (args) => {
|
|
524
|
+
const css = await readFile(args.path, 'utf8');
|
|
525
|
+
|
|
526
|
+
if (!warnedCssFiles.has(args.path) && /(^|[\s,}])(html|body)\s*[,{[.:>#]/m.test(css)) {
|
|
527
|
+
warnedCssFiles.add(args.path);
|
|
528
|
+
console.warn(
|
|
529
|
+
`Warning: ${path.relative(process.cwd(), args.path)} styles html/body — those selectors match nothing ` +
|
|
530
|
+
'inside the shadow root; rescope them to a wrapper class (see WEBIM_PLUGIN.md, Rendering rules).'
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return { contents: css.replace(/:root\b/g, ':host'), loader: 'text' };
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
|
|
175
539
|
async function bundle(config) {
|
|
176
540
|
const esbuild = await import('esbuild');
|
|
177
541
|
const result = await esbuild.build({
|
|
@@ -182,7 +546,12 @@ async function bundle(config) {
|
|
|
182
546
|
platform: 'browser',
|
|
183
547
|
target: 'es2020',
|
|
184
548
|
minify: false,
|
|
185
|
-
logLevel: 'silent'
|
|
549
|
+
logLevel: 'silent',
|
|
550
|
+
// Modern JSX runtime (React 17+/Vite default): .jsx/.tsx files work
|
|
551
|
+
// without a manual `import React` in every file.
|
|
552
|
+
jsx: 'automatic',
|
|
553
|
+
loader: LOADERS,
|
|
554
|
+
plugins: [cssRescopePlugin]
|
|
186
555
|
});
|
|
187
556
|
|
|
188
557
|
if (result.errors.length) throw new Error(result.errors.map((e) => e.text).join('\n'));
|
|
@@ -230,9 +599,13 @@ function validatePlugin(plugin) {
|
|
|
230
599
|
async function build() {
|
|
231
600
|
const config = await loadConfig();
|
|
232
601
|
const out = await bundle(config);
|
|
233
|
-
// Import the BUILT
|
|
234
|
-
// DOM access would fail right here — keep DOM work inside mount().
|
|
235
|
-
|
|
602
|
+
// Import the BUILT code: what ships is what gets validated. Module-level
|
|
603
|
+
// DOM access would fail right here — keep DOM work inside mount(). A data:
|
|
604
|
+
// URL instead of the file path so Node always parses it as ESM — a file
|
|
605
|
+
// import would inherit the project's package.json "type", and a project
|
|
606
|
+
// without "type": "module" breaks on the bundle's export statement.
|
|
607
|
+
const code = await readFile(path.resolve(out), 'utf8');
|
|
608
|
+
const mod = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`);
|
|
236
609
|
const manifest = validatePlugin(mod?.default);
|
|
237
610
|
|
|
238
611
|
console.log(`Built ${out}`);
|
|
@@ -336,7 +709,10 @@ async function dev() {
|
|
|
336
709
|
platform: 'browser',
|
|
337
710
|
target: 'es2020',
|
|
338
711
|
logLevel: 'silent',
|
|
712
|
+
jsx: 'automatic',
|
|
713
|
+
loader: LOADERS,
|
|
339
714
|
plugins: [
|
|
715
|
+
cssRescopePlugin,
|
|
340
716
|
{
|
|
341
717
|
name: 'webim-push',
|
|
342
718
|
setup(buildHook) {
|
|
@@ -372,6 +748,7 @@ try {
|
|
|
372
748
|
|
|
373
749
|
console.log(pkg.version);
|
|
374
750
|
} else if (command === 'init') await init(argument);
|
|
751
|
+
else if (command === 'wrap') await wrap(argument);
|
|
375
752
|
else if (command === 'build') await build();
|
|
376
753
|
else if (command === 'push') await push();
|
|
377
754
|
else if (command === 'dev') await dev();
|
package/package.json
CHANGED