znode-plugin 0.0.0-stage → 0.1.2
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 +84 -2
- package/lib/css-dedupe.mjs +62 -0
- package/lib/next-imports.mjs +298 -0
- package/lib/templates.mjs +316 -0
- package/lib/znode-package.mjs +680 -0
- package/package.json +30 -4
- package/tsconfig.json +18 -0
- package/types/extension-api.d.ts +227 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// Starter packages for `znode create <kind> <name>`: each builds, installs and runs as it is, and shows
|
|
2
|
+
// every extension point of its kind once, with a comment on when to use it. Files by path, relative to the package.
|
|
3
|
+
|
|
4
|
+
const titleCase = (name) => name.replace(/(^|-)([a-z0-9])/g, (_, dash, letter) => `${dash ? " " : ""}${letter.toUpperCase()}`);
|
|
5
|
+
const pascalCase = (name) => name.replace(/(^|-)([a-z0-9])/g, (_, __, letter) => letter.toUpperCase());
|
|
6
|
+
|
|
7
|
+
const HOST = `import type { IBrowserHost, IReact } from "../extension-api";
|
|
8
|
+
|
|
9
|
+
// The store's React, bound once by setup (browser.tsx); the other modules import these bindings, which stay live.
|
|
10
|
+
// Module scope is per store on the server and per page in the browser, so a cache here never mixes two stores.
|
|
11
|
+
|
|
12
|
+
export let React: IReact;
|
|
13
|
+
// JSX compiles to h(...) / Fragment, bound to the store's React in setup
|
|
14
|
+
export let h: IReact["createElement"];
|
|
15
|
+
export let Fragment: (props: { children?: unknown }) => unknown;
|
|
16
|
+
export let settings: Record<string, unknown> = {};
|
|
17
|
+
|
|
18
|
+
export function bindHost(host: IBrowserHost) {
|
|
19
|
+
React = host.React;
|
|
20
|
+
h = React.createElement;
|
|
21
|
+
Fragment = React.Fragment as typeof Fragment;
|
|
22
|
+
settings = host.settings;
|
|
23
|
+
}
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const buildScript = (name) => `#!/usr/bin/env sh
|
|
27
|
+
# Packs ${name} (znode-package.json) and installs it into the store's plugin directory.
|
|
28
|
+
# Usage: sh build-and-upload.sh [plugin directory]
|
|
29
|
+
set -eu
|
|
30
|
+
cd "$(dirname "$0")"
|
|
31
|
+
npx znode pack .
|
|
32
|
+
npx znode install "releases/$(node -p "const m=require('./znode-package.json'); m.name + '-' + m.version").zpkg.zip" "\${1:-\${RUNTIME_PLUGINS_DIR:?set RUNTIME_PLUGINS_DIR or pass the store folder}}"
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
const marketplace = (name, kind, permissions, extra = {}) => ({
|
|
36
|
+
name,
|
|
37
|
+
version: "1.0.0",
|
|
38
|
+
kind,
|
|
39
|
+
apiVersion: 1,
|
|
40
|
+
label: titleCase(name),
|
|
41
|
+
publisher: "Your company",
|
|
42
|
+
description: `${titleCase(name)}: describe what it does for the store in one or two sentences.`,
|
|
43
|
+
license: "proprietary",
|
|
44
|
+
keywords: [kind],
|
|
45
|
+
// what the package does beyond showing UI, for the marketplace review (znode-package.mjs check compares it with the code)
|
|
46
|
+
permissions,
|
|
47
|
+
compatibility: { webstore: ">=10.0.0" },
|
|
48
|
+
...extra,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const widgetSource = (component) => `import { h } from "../lib/host.js";
|
|
52
|
+
|
|
53
|
+
// ${component}: a page-builder widget. The editor (EDITOR) gives it a name, a category and its fields; a merchant drops
|
|
54
|
+
// it on any page and fills them in. Props arrive as saved; keep the server's HTML and the first browser render equal
|
|
55
|
+
// (no Date.now() or window during render: use an effect).
|
|
56
|
+
|
|
57
|
+
type I${component}Props = { id?: string; heading?: string; text?: string; buttonText?: string; buttonUrl?: string; tone?: "light" | "dark" };
|
|
58
|
+
|
|
59
|
+
export function ${component}(props: I${component}Props) {
|
|
60
|
+
const dark = props.tone === "dark";
|
|
61
|
+
return (
|
|
62
|
+
<section
|
|
63
|
+
className="${component.replace(/[A-Z]/g, (letter, index) => (index ? "-" : "") + letter.toLowerCase())}"
|
|
64
|
+
data-test-selector={\`div${component}\${props.id || ""}\`}
|
|
65
|
+
style={{ padding: "48px 24px", textAlign: "center", background: dark ? "#111" : "var(--secondary-bg, #f6f6f6)", color: dark ? "#fff" : "inherit" }}
|
|
66
|
+
>
|
|
67
|
+
{props.heading && <h2 style={{ margin: "0 0 12px", fontSize: 32 }}>{props.heading}</h2>}
|
|
68
|
+
{props.text && <p style={{ margin: "0 auto 20px", maxWidth: 640 }}>{props.text}</p>}
|
|
69
|
+
{props.buttonText && props.buttonUrl && (
|
|
70
|
+
<a className="btn btn-primary" href={props.buttonUrl} data-test-selector="link${component}">
|
|
71
|
+
{props.buttonText}
|
|
72
|
+
</a>
|
|
73
|
+
)}
|
|
74
|
+
</section>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const EDITOR = {
|
|
79
|
+
label: "${titleCase(component.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase())}",
|
|
80
|
+
category: "Content",
|
|
81
|
+
fields: {
|
|
82
|
+
heading: { type: "text", label: "Heading" },
|
|
83
|
+
text: { type: "textarea", label: "Text" },
|
|
84
|
+
buttonText: { type: "text", label: "Button text" },
|
|
85
|
+
buttonUrl: { type: "text", label: "Button link" },
|
|
86
|
+
tone: { type: "radio", label: "Tone", options: [{ value: "light", label: "Light" }, { value: "dark", label: "Dark" }] },
|
|
87
|
+
},
|
|
88
|
+
defaultProps: { heading: "Made for your business", text: "Tell buyers what makes this store worth their time.", buttonText: "Shop now", buttonUrl: "/", tone: "light" },
|
|
89
|
+
};
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
function theme(name) {
|
|
93
|
+
const widget = `${pascalCase(name)}Hero`;
|
|
94
|
+
return {
|
|
95
|
+
"znode-package.json": marketplace(name, "theme", [], {
|
|
96
|
+
build: { browser: "src/browser.tsx" },
|
|
97
|
+
styles: ["src/styles/*.css"],
|
|
98
|
+
messages: "src/i18n/labels.json",
|
|
99
|
+
widgets: [widget],
|
|
100
|
+
settingsSchema: {
|
|
101
|
+
announcement: { type: "string", label: "Announcement bar text (empty: no bar)", browser: true, default: "", attribute: "ThemeAnnouncement" },
|
|
102
|
+
accentColor: { type: "string", label: "Accent color (CSS)", browser: true, default: "#c4122f", attribute: "ThemeAccentColor" },
|
|
103
|
+
showBadges: { type: "boolean", label: "Show the New badge on product cards", browser: true, default: true },
|
|
104
|
+
},
|
|
105
|
+
}),
|
|
106
|
+
"build-and-upload.sh": buildScript(name),
|
|
107
|
+
"src/lib/host.ts": HOST,
|
|
108
|
+
"src/browser.tsx": `import type { IBrowserHost, IBrowserPlugin } from "./extension-api";
|
|
109
|
+
import { bindHost, Fragment, h, settings } from "./lib/host.js";
|
|
110
|
+
import { ${widget}, EDITOR } from "./widgets/${widget}.js";
|
|
111
|
+
|
|
112
|
+
// ${titleCase(name)} theme, browser part. A theme changes the store three ways, cheapest first:
|
|
113
|
+
// 1. CSS (src/styles): the store's own markup restyled through its tokens and data-test-selector attributes.
|
|
114
|
+
// 2. Slots: wrap a store component (children is what the store renders) or replace it. Names: extension-api.d.ts.
|
|
115
|
+
// 3. Widgets: new page-builder blocks.
|
|
116
|
+
// Settings come from the registry or, per store, from Znode attributes (settingsSchema "attribute").
|
|
117
|
+
|
|
118
|
+
function Header(props: { children?: unknown }) {
|
|
119
|
+
const text = String(settings.announcement || "");
|
|
120
|
+
return (
|
|
121
|
+
<>
|
|
122
|
+
{text && (
|
|
123
|
+
<div className="theme-announcement" data-test-selector="divThemeAnnouncement">
|
|
124
|
+
{text}
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
{props.children}
|
|
128
|
+
</>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function ProductCard(props: { children?: unknown; product?: { isNew?: boolean; attributes?: { attributeCode?: string; attributeValues?: string }[] } }) {
|
|
133
|
+
const isNew = props.product?.attributes?.some((item) => item.attributeCode === "NewProduct" && item.attributeValues === "true");
|
|
134
|
+
return (
|
|
135
|
+
<div className="theme-card">
|
|
136
|
+
{settings.showBadges !== false && isNew && <span className="theme-badge">New</span>}
|
|
137
|
+
{props.children}
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export default function setup(host: IBrowserHost): IBrowserPlugin {
|
|
143
|
+
bindHost(host);
|
|
144
|
+
if (typeof document !== "undefined" && settings.accentColor) document.documentElement.style.setProperty("--theme-accent", String(settings.accentColor));
|
|
145
|
+
return {
|
|
146
|
+
slots: { "layout.header": Header, "product.card": ProductCard },
|
|
147
|
+
widgets: { ${widget}: { render: ${widget}, ...EDITOR } },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
`,
|
|
151
|
+
[`src/widgets/${widget}.tsx`]: widgetSource(widget),
|
|
152
|
+
"src/styles/10-tokens.css": `/* ${titleCase(name)}: the store's design tokens. Change these first; most of the store follows them. */
|
|
153
|
+
:root {
|
|
154
|
+
--theme-accent: #c4122f;
|
|
155
|
+
--primary-btn-bg: var(--theme-accent);
|
|
156
|
+
--primary-btn-text-color: #fff;
|
|
157
|
+
--font-family: "Inter", system-ui, sans-serif;
|
|
158
|
+
}
|
|
159
|
+
`,
|
|
160
|
+
"src/styles/20-parts.css": `/* Parts of the store's own pages, found by their data-test-selector (stable across releases). */
|
|
161
|
+
.theme-announcement {
|
|
162
|
+
background: var(--theme-accent);
|
|
163
|
+
color: #fff;
|
|
164
|
+
text-align: center;
|
|
165
|
+
padding: 8px 16px;
|
|
166
|
+
font-size: 14px;
|
|
167
|
+
}
|
|
168
|
+
.theme-card {
|
|
169
|
+
position: relative;
|
|
170
|
+
}
|
|
171
|
+
.theme-badge {
|
|
172
|
+
position: absolute;
|
|
173
|
+
top: 8px;
|
|
174
|
+
left: 8px;
|
|
175
|
+
z-index: 1;
|
|
176
|
+
background: var(--theme-accent);
|
|
177
|
+
color: #fff;
|
|
178
|
+
font-size: 12px;
|
|
179
|
+
padding: 2px 8px;
|
|
180
|
+
}
|
|
181
|
+
[data-test-selector="btnAddToCart"] {
|
|
182
|
+
border-radius: 0;
|
|
183
|
+
}
|
|
184
|
+
`,
|
|
185
|
+
"src/i18n/labels.json": JSON.stringify({ Common: { addToCart: "Add to cart" } }, null, 2) + "\n",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function plugin(name) {
|
|
190
|
+
return {
|
|
191
|
+
"znode-package.json": marketplace(name, "plugin", ["routes", "cartHooks"], {
|
|
192
|
+
build: { server: "src/server.ts", browser: "src/browser.tsx" },
|
|
193
|
+
settingsSchema: {
|
|
194
|
+
maxQuantity: {
|
|
195
|
+
type: "number",
|
|
196
|
+
label: "Most units of one SKU per order line (0: no limit)",
|
|
197
|
+
default: 0,
|
|
198
|
+
attribute: { code: "MaxLineQuantity", levels: ["store", "account"] },
|
|
199
|
+
},
|
|
200
|
+
note: { type: "string", label: "Note shown under Add to cart", browser: true, default: "Volume pricing for business accounts." },
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
"build-and-upload.sh": buildScript(name),
|
|
204
|
+
"src/server.ts": `import type { IPluginRouteContext, IPluginRouteRequest, IRuntimeData, IRuntimeHookContext, IRuntimePlugin } from "./extension-api";
|
|
205
|
+
|
|
206
|
+
// ${titleCase(name)}, server part. Runs inside the webstore for the stores it is installed for:
|
|
207
|
+
// routes /api/extensions/plugins/${name}/<route>: your own endpoints (JSON, or a Response for files)
|
|
208
|
+
// hooks change carts and add-to-cart requests on their way to Znode
|
|
209
|
+
// "overrides" in znode-package.json: take over a store API route (call context.original() to run the store's own)
|
|
210
|
+
// Settings: context.settings, with those bound to Znode attributes already resolved for the store and the buyer.
|
|
211
|
+
|
|
212
|
+
async function limits(_request: IPluginRouteRequest, context: IPluginRouteContext) {
|
|
213
|
+
// the buyer's B2B details come from the session: use them for role, account and budget rules
|
|
214
|
+
return { maxQuantity: Number(context.settings.maxQuantity) || 0, account: context.user?.accountCode || null, role: context.user?.roleName || null };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function beforeAddToCart(request: IRuntimeData, context: IRuntimeHookContext): Promise<IRuntimeData | void> {
|
|
218
|
+
const max = Number(context.settings.maxQuantity) || 0;
|
|
219
|
+
if (!max) return;
|
|
220
|
+
const quantity = Number(request.quantity);
|
|
221
|
+
if (quantity > max) return { ...request, quantity: max };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const plugin: IRuntimePlugin = {
|
|
225
|
+
name: "${name}",
|
|
226
|
+
apiVersion: 1,
|
|
227
|
+
routes: { limits },
|
|
228
|
+
hooks: { beforeAddToCart },
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
export default plugin;
|
|
232
|
+
`,
|
|
233
|
+
"src/lib/host.ts": HOST,
|
|
234
|
+
"src/browser.tsx": `import type { IBrowserHost, IBrowserPlugin } from "./extension-api";
|
|
235
|
+
import { bindHost, Fragment, h, React, settings } from "./lib/host.js";
|
|
236
|
+
|
|
237
|
+
// ${titleCase(name)}, browser part: a slot around the product page's Add to cart, asking this plugin's route for the
|
|
238
|
+
// buyer's limit (account-specific, so it is not in the cacheable manifest).
|
|
239
|
+
|
|
240
|
+
function AddToCart(props: { children?: unknown }) {
|
|
241
|
+
const [max, setMax] = React.useState(0);
|
|
242
|
+
React.useEffect(() => {
|
|
243
|
+
fetch("/api/extensions/plugins/${name}/limits")
|
|
244
|
+
.then((response) => (response.ok ? response.json() : null))
|
|
245
|
+
.then((data) => setMax(Number(data?.maxQuantity) || 0))
|
|
246
|
+
.catch(() => setMax(0));
|
|
247
|
+
}, []);
|
|
248
|
+
return (
|
|
249
|
+
<>
|
|
250
|
+
{props.children}
|
|
251
|
+
<p className="small" data-test-selector="txt${pascalCase(name)}Note">
|
|
252
|
+
{String(settings.note || "")}
|
|
253
|
+
{max > 0 && \` Up to \${max} per line.\`}
|
|
254
|
+
</p>
|
|
255
|
+
</>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export default function setup(host: IBrowserHost): IBrowserPlugin {
|
|
260
|
+
bindHost(host);
|
|
261
|
+
return { slots: { "product.addToCart": AddToCart } };
|
|
262
|
+
}
|
|
263
|
+
`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function widget(name) {
|
|
268
|
+
const component = pascalCase(name);
|
|
269
|
+
return {
|
|
270
|
+
"znode-package.json": marketplace(name, "widget", [], { build: { browser: "src/browser.tsx" }, widgets: [component] }),
|
|
271
|
+
"build-and-upload.sh": buildScript(name),
|
|
272
|
+
"src/lib/host.ts": HOST,
|
|
273
|
+
"src/browser.tsx": `import type { IBrowserHost, IBrowserPlugin } from "./extension-api";
|
|
274
|
+
import { bindHost } from "./lib/host.js";
|
|
275
|
+
import { ${component}, EDITOR } from "./widgets/${component}.js";
|
|
276
|
+
|
|
277
|
+
// ${titleCase(name)}: a widget package. It brings page-builder widgets and nothing else, so any store with any theme can
|
|
278
|
+
// place them. Use the theme's CSS variables for colors so it fits every theme.
|
|
279
|
+
|
|
280
|
+
export default function setup(host: IBrowserHost): IBrowserPlugin {
|
|
281
|
+
bindHost(host);
|
|
282
|
+
return { widgets: { ${component}: { render: ${component}, ...EDITOR } } };
|
|
283
|
+
}
|
|
284
|
+
`,
|
|
285
|
+
[`src/widgets/${component}.tsx`]: widgetSource(component),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function content(name) {
|
|
290
|
+
return {
|
|
291
|
+
"znode-package.json": marketplace(name, "content", [], { pages: { "/landing": "src/pages/landing.json" } }),
|
|
292
|
+
"build-and-upload.sh": buildScript(name),
|
|
293
|
+
"src/pages/landing.json":
|
|
294
|
+
JSON.stringify(
|
|
295
|
+
{
|
|
296
|
+
root: { props: { contentWidth: "full" } },
|
|
297
|
+
content: [
|
|
298
|
+
{ type: "Heading", props: { id: "Heading-landing", text: "Landing page", align: "center" } },
|
|
299
|
+
{
|
|
300
|
+
type: "Text",
|
|
301
|
+
props: {
|
|
302
|
+
id: "Text-landing",
|
|
303
|
+
text: "Shown at /landing for the stores this package is installed for. Build the page in the page builder and paste its saved JSON here.",
|
|
304
|
+
align: "center",
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
zones: {},
|
|
309
|
+
},
|
|
310
|
+
null,
|
|
311
|
+
2
|
|
312
|
+
) + "\n",
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export const TEMPLATES = { theme, plugin, widget, content };
|