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 CHANGED
@@ -1,3 +1,85 @@
1
- # Temporary Holding Version
1
+ # znode-plugin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The SDK for Znode runtime plugins and themes: the `znode` command behind every project made with
4
+ `create-znode-plugin`. A package is built on its own and runs in the webstore for the stores it is enabled for, with no
5
+ fork of the store and no webstore rebuild.
6
+
7
+ ## Getting started
8
+
9
+ Start a new project with the recipe (any one of these, they are the same):
10
+
11
+ ```bash
12
+ npm create znode-plugin@latest my-theme
13
+ npx create-znode-plugin@latest my-theme
14
+ npm init znode-plugin@latest my-theme
15
+ ```
16
+
17
+ It then asks two things, so have them ready:
18
+
19
+ 1. **The store path to connect**: the store repo, the folder with `tsconfig.base.json` at its root. The project gets
20
+ the store's real types from it (`znode link-store`).
21
+ 2. **The store's plugin folder**: where the webstore loads runtime packages from (its `RUNTIME_PLUGINS_DIR`, the folder
22
+ with `plugins.json`). Usually found for you in the store's `apps/webstore/.env.local` (or `.env`); asked only when
23
+ the store does not set it. `npm run dev` and `npm run deploy` copy your package there.
24
+
25
+ Then:
26
+
27
+ ```bash
28
+ cd my-theme
29
+ npm run dev
30
+ ```
31
+
32
+ and, with the webstore running in development mode, preview the theme in your browser only:
33
+ `http://localhost:3000/api/extensions/preview?theme=my-theme`. The full walkthrough (every question, what to enter,
34
+ skipping, scripts and CI, troubleshooting): [`create-znode-plugin`](https://www.npmjs.com/package/create-znode-plugin).
35
+
36
+ To add the SDK to an existing package instead:
37
+
38
+ ```bash
39
+ npm install -D znode-plugin
40
+ ```
41
+
42
+ ## Connecting a project to a store
43
+
44
+ | What | How | Saved in |
45
+ | --- | --- | --- |
46
+ | The store's types | `npm run link-store -- <store repo>` (`znode link-store <storeRepo> [dir]`) | `tsconfig.store.json` (git-ignored) |
47
+ | The store's plugin folder | Answer the recipe's question, or write it yourself: `{ "pluginsDir": "D:\\znode\\runtime-plugins-store" }` | `znode.local.json` (git-ignored) |
48
+
49
+ Both are per machine: each developer connects their own checkout. Run `link-store` again after the store adds or moves
50
+ modules.
51
+
52
+ ## Commands
53
+
54
+ | Command | What it does |
55
+ | --- | --- |
56
+ | `znode build <dir>` | Type-check (with a `tsconfig.json`), then build the server and browser bundles into `dist/` |
57
+ | `znode check <dir>` | Marketplace review: metadata, permissions against the code, settings, sizes |
58
+ | `znode pack <dir>` | Build and zip into `releases/<name>-<version>.zpkg.zip` |
59
+ | `znode install <zip\|dir> [storeDir]` | Install into the store's plugin folder (keeps the store's settings and stores) |
60
+ | `znode deploy <dir> [storeDir]` | Pack, then install |
61
+ | `znode dev <dir> [storeDir]` | Deploy, then again on every change under `src/` or to `znode-package.json` |
62
+ | `znode link-store <storeRepo> [dir]` | Writes `tsconfig.store.json` so `@znode/*` imports get the store's real types |
63
+ | `znode create <kind> <name> [dir]` | A minimal starter of a kind (theme, plugin, widget, content) |
64
+
65
+ `storeDir` (the store's `RUNTIME_PLUGINS_DIR`): the argument, else the `RUNTIME_PLUGINS_DIR` environment variable, else
66
+ `znode.local.json` (`{ "pluginsDir": "…" }`) in the folder the command runs in. With none of them, `deploy`, `install`
67
+ and `dev` stop with `needs the store's plugin folder`.
68
+
69
+ ## After deploying
70
+
71
+ - **Preview** (your browser only, webstore in development mode):
72
+ `/api/extensions/preview?theme=<name>`, `…&to=/<path>` to open a page, `/api/extensions/preview` to stop.
73
+ `/api/extensions/preview?list` lists the themes the store can see.
74
+ - **Live for a store**: in the plugin folder's `plugins.json`, set the package's `"enabled": true` and add the store
75
+ codes to `"stores"`. Deploying again keeps those settings.
76
+
77
+ ## Imports in a package
78
+
79
+ | Import | At runtime |
80
+ | --- | --- |
81
+ | `react`, `react/jsx-runtime`, `next/image`, `next/link`, `next/navigation` | The store's own copies (one React, its router and images config) |
82
+ | `next/font/google` | Built into the package: the font files are downloaded at build time |
83
+ | `@znode/…` store modules the store publishes | The store's own modules, in browser parts |
84
+
85
+ Types for the package API (slots, widgets, server slots, server pages, routes, hooks): `znode-plugin/types/extension-api`.
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ // Drops the rules a store already has from a stylesheet copied from another site (live widget CSS), so only the missing
3
+ // ones ship: a repeated utility class loaded later would otherwise win over the store's own responsive order.
4
+ // Usage: node css-dedupe.mjs <in.css> <storePageUrl> <out.css>
5
+ import fs from "node:fs";
6
+ import http from "node:http";
7
+ import path from "node:path";
8
+ import { createRequire } from "node:module";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
12
+ // postcss from ZNODE_TOOLS, else the webstore repo's node_modules (as znode-package.mjs)
13
+ const require = createRequire(path.join(process.env.ZNODE_TOOLS || path.resolve(HERE, "../../node_modules"), "noop.js"));
14
+ const postcss = require("postcss");
15
+
16
+ const [, , input, pageUrl, output] = process.argv;
17
+ const key = (rule) => {
18
+ let parent = rule.parent;
19
+ const wrappers = [];
20
+ while (parent && parent.type === "atrule") {
21
+ wrappers.unshift(`@${parent.name} ${parent.params}`);
22
+ parent = parent.parent;
23
+ }
24
+ return `${wrappers.join(" ")}|${rule.selector.replace(/\s+/g, " ").trim()}`;
25
+ };
26
+
27
+ // *.localhost store names resolve to this machine (Node's DNS lookup doesn't know them)
28
+ const fetchText = (url) => {
29
+ const target = new URL(url);
30
+ if (!target.hostname.endsWith(".localhost")) return fetch(url).then((response) => response.text());
31
+ return new Promise((resolve, reject) => {
32
+ http
33
+ .get({ host: "127.0.0.1", port: target.port || 80, path: target.pathname + target.search, headers: { host: target.host } }, (response) => {
34
+ let body = "";
35
+ response.setEncoding("utf8");
36
+ response.on("data", (chunk) => (body += chunk));
37
+ response.on("end", () => resolve(body));
38
+ })
39
+ .on("error", reject);
40
+ });
41
+ };
42
+
43
+ const html = await fetchText(pageUrl);
44
+ const hrefs = [...html.matchAll(/<link[^>]+rel="stylesheet"[^>]+href="([^"]+)"/g)].map((match) => new URL(match[1].replace(/&amp;/g, "&"), pageUrl).href);
45
+ const known = new Set();
46
+ for (const href of hrefs) {
47
+ if (href.includes("/api/extensions/")) continue;
48
+ postcss.parse(await fetchText(href)).walkRules((rule) => known.add(key(rule)));
49
+ }
50
+ const root = postcss.parse(fs.readFileSync(input, "utf8"));
51
+ let dropped = 0;
52
+ root.walkRules((rule) => {
53
+ if (known.has(key(rule))) {
54
+ rule.remove();
55
+ dropped++;
56
+ }
57
+ });
58
+ root.walkAtRules((atRule) => {
59
+ if (atRule.nodes && atRule.nodes.length === 0) atRule.remove();
60
+ });
61
+ fs.writeFileSync(output, root.toString());
62
+ console.log(`css-dedupe: ${dropped} rules the store already has dropped, ${fs.statSync(output).size} bytes kept (${hrefs.length} store stylesheets)`);
@@ -0,0 +1,298 @@
1
+ // Next.js imports in a package, as a Next.js app writes them: import Image from "next/image", import Link from
2
+ // "next/link", import { useRouter } from "next/navigation", import { Montserrat } from "next/font/google".
3
+ //
4
+ // A package is built here, not by the store's Next.js, and the store loads it at runtime, so it cannot bundle Next
5
+ // itself (a second React and Next) and nothing compiles next/font for it. This rollup plugin, used by
6
+ // znode-package.mjs build, does both jobs instead:
7
+ // next/image, next/link, next/navigation, react: stand-ins that use the store's own modules at runtime (the store
8
+ // publishes them: packages/extensions/src/next-modules.server.ts for server parts, client/runtime.ts for browser
9
+ // parts), so a package renders the store's next/image with the store's images config, one React, one router
10
+ // next/font/google: what next/font does, at the package's build: the font's files are downloaded from Google into
11
+ // the package (served by the store with its other files), @font-face rules and a class are added to the package's
12
+ // stylesheet, and the call is replaced by { className, style: { fontFamily }, variable }
13
+ // Any other next/* import stops the build: it names what is supported.
14
+
15
+ import fs from "fs";
16
+ import path from "path";
17
+ import vm from "vm";
18
+ import crypto from "crypto";
19
+
20
+ // what a package may import, per module: names it can use, and which are components (rendered by the store's own)
21
+ const MODULES = {
22
+ "next/image": { default: "component" },
23
+ "next/link": { default: "component" },
24
+ "next/navigation": {
25
+ useRouter: "function",
26
+ usePathname: "function",
27
+ useSearchParams: "function",
28
+ useParams: "function",
29
+ redirect: "function",
30
+ permanentRedirect: "function",
31
+ notFound: "function",
32
+ },
33
+ react: {
34
+ // import React from "react": the store's React (React.useState, React.memo, ...)
35
+ default: "namespace",
36
+ createElement: "function",
37
+ cloneElement: "function",
38
+ isValidElement: "function",
39
+ Fragment: "value",
40
+ Suspense: "value",
41
+ Children: "value",
42
+ useState: "function",
43
+ useEffect: "function",
44
+ useLayoutEffect: "function",
45
+ useRef: "function",
46
+ useMemo: "function",
47
+ useCallback: "function",
48
+ useReducer: "function",
49
+ useId: "function",
50
+ useTransition: "function",
51
+ useSyncExternalStore: "function",
52
+ },
53
+ // JSX compiled as Next.js does it (a package with its own tsconfig.json): the store's React creates the elements
54
+ "react/jsx-runtime": { jsx: "function", jsxs: "function", Fragment: "value" },
55
+ };
56
+ // the server-only functions a browser part may not need, and the hooks a server part cannot call, stay importable:
57
+ // the store's module for that side decides (a hook in a server part fails as it would in a Next.js server component)
58
+
59
+ // The store's own modules a browser part may import by the store's path, so a theme's own component (a copy of the
60
+ // store's, say) keeps the store's imports instead of re-creating them: relative paths become "@znode/..." paths (the
61
+ // store publishes them: packages/extensions/src/client/store-modules.ts; keep the two lists the same). Types for
62
+ // package authors: sdk/store-modules.d.ts. Kinds: component (rendered by the store's React), function, object (its
63
+ // properties read when used, its methods bound to it).
64
+ const STORE_MODULES = {
65
+ lodash: { debounce: "function" },
66
+ "next-intl": { useTranslations: "function" },
67
+ "@znode/logger": { logClient: "object" },
68
+ "@znode/logger/server": { AREA: "object" },
69
+ "@znode/constants/settings": { SETTINGS: "object" },
70
+ "@znode/constants/user-activity-event": { USER_ACTIVITY_EVENT: "object" },
71
+ "@znode/utils/common": { getValidatedRedirectPath: "function" },
72
+ "@znode/base-components/components/common/icons": { LoadingSpinner: "component", SearchIcon: "component", ZIcons: "component" },
73
+ "@znode/base-components/components/common/button/Button": { default: "component" },
74
+ "@znode/base-components/components/common/input/Input": { default: "component" },
75
+ "@znode/base-components/components/common/image": { CustomImage: "component" },
76
+ "@znode/base-components/http-request": {
77
+ getHydratedSuggestionSearch: "function",
78
+ getHydratedTypeaheadSearchContent: "function",
79
+ getSearchRedirectURL: "function",
80
+ userActivityLog: "function",
81
+ },
82
+ "@znode/base-components/components/layout-components/header/barcode-scanner/BarcodeScanner": { BarcodeScanner: "component" },
83
+ "@znode/base-components/components/layout-components/header/search/Search": { DynamicVoiceSearch: "component" },
84
+ "@znode/base-components/components/layout-components/header/search-box": { SearchBox: "component" },
85
+ "@znode/base-components/components/layout-components/header/search-box/TypeaheadSearch": { TypeaheadSearch: "component" },
86
+ "@znode/base-components/components/layout-components/header/search-box/HydratedSearchList": { HydratedSearchList: "component" },
87
+ };
88
+ Object.assign(MODULES, STORE_MODULES);
89
+
90
+ // react's values, the same in every React 19 build and never read from the store at load
91
+ const VALUES = {
92
+ Fragment: `Symbol.for("react.fragment")`,
93
+ Suspense: `Symbol.for("react.suspense")`,
94
+ Children: `{ map: (...args) => mod().Children.map(...args), forEach: (...args) => mod().Children.forEach(...args), count: (...args) => mod().Children.count(...args), toArray: (...args) => mod().Children.toArray(...args), only: (...args) => mod().Children.only(...args) }`,
95
+ };
96
+
97
+ const FONT_MODULE = "next/font/google";
98
+ const VIRTUAL = "\0znode-next:";
99
+ const CHROME = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
100
+
101
+ // the stand-in module: each name reads the store's module when used, never at load (a server part is also loaded where
102
+ // nothing renders, e.g. for its routes)
103
+ function standIn(id, part) {
104
+ const global = part === "server" ? "__znodeNextServer" : "__znodeNextClient";
105
+ const lines = [
106
+ `const store = () => globalThis.${global} || {};`,
107
+ `const mod = () => { const m = store()[${JSON.stringify(id)}]; if (!m) throw new Error(${JSON.stringify(`${id} is not available to this package part`)}); return m; };`,
108
+ ];
109
+ for (const [name, kind] of Object.entries(MODULES[id])) {
110
+ const local = name === "default" ? "__default" : name;
111
+ if (kind === "component") {
112
+ lines.push(`function ${local}(props) { return store().react.createElement(mod()[${JSON.stringify(name)}], props); }`);
113
+ } else if (kind === "function") {
114
+ lines.push(`const ${local} = (...args) => mod()[${JSON.stringify(name)}](...args);`);
115
+ } else if (kind === "object" || kind === "namespace") {
116
+ // an object (SETTINGS, logClient) or the module itself (React): each property read when used, methods bound to it
117
+ const target = kind === "namespace" ? "mod()" : `mod()[${JSON.stringify(name)}]`;
118
+ lines.push(
119
+ `const ${local} = new Proxy({}, { get: (_, key) => { const target = ${target}; const value = target[key]; return typeof value === "function" ? value.bind(target) : value; }, has: (_, key) => key in ${target} });`
120
+ );
121
+ } else {
122
+ // a value, which must not depend on when the package loads (Node keeps a loaded module): React's element types
123
+ // are global symbols, Children forwards each call
124
+ lines.push(`const ${local} = ${VALUES[name]};`);
125
+ }
126
+ lines.push(name === "default" ? `export default ${local};` : `export { ${local} };`);
127
+ }
128
+ return lines.join("\n");
129
+ }
130
+
131
+ // ---- next/font/google ----------------------------------------------------------------------------------------------
132
+
133
+ // the call's options, a literal object as next/font requires (no variables, no calls)
134
+ function readOptions(text, where) {
135
+ try {
136
+ // the timeout only stops a runaway expression: a busy machine (a build or type-check running) needs room
137
+ return vm.runInNewContext(`(${text || "{}"})`, Object.create(null), { timeout: 2000 });
138
+ } catch (error) {
139
+ throw new Error(`${where}: next/font/google options must be a literal object, as in Next.js (${error?.message || error}): ${text}`);
140
+ }
141
+ }
142
+
143
+ // from an opening parenthesis to its closing one, skipping strings
144
+ function closingParen(code, open) {
145
+ let depth = 0;
146
+ let quote = "";
147
+ for (let index = open; index < code.length; index++) {
148
+ const char = code[index];
149
+ if (quote) {
150
+ if (char === "\\") index++;
151
+ else if (char === quote) quote = "";
152
+ } else if (char === '"' || char === "'" || char === "`") quote = char;
153
+ else if (char === "(") depth++;
154
+ else if (char === ")" && --depth === 0) return index;
155
+ }
156
+ return -1;
157
+ }
158
+
159
+ const asList = (value) => (value === undefined ? [] : Array.isArray(value) ? value.map(String) : [String(value)]);
160
+
161
+ async function fetchText(url) {
162
+ const response = await fetch(url, { headers: { "User-Agent": CHROME } });
163
+ return { status: response.status, text: await response.text() };
164
+ }
165
+
166
+ // the Google Fonts CSS for a family: the weights and styles asked for, else the variable range, else the default
167
+ async function googleCss(family, options) {
168
+ const weights = asList(options.weight);
169
+ const styles = asList(options.style);
170
+ const name = family.replace(/_/g, " ");
171
+ const base = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(name).replace(/%20/g, "+")}`;
172
+ const display = `&display=${options.display || "swap"}`;
173
+ const italic = styles.includes("italic");
174
+ const tries = [];
175
+ if (weights.length && !weights.includes("variable")) {
176
+ const pairs = weights.flatMap((weight) => (italic ? [`0,${weight}`, `1,${weight}`] : [weight])).sort();
177
+ tries.push(`${base}:${italic ? "ital," : ""}wght@${pairs.join(";")}${display}`);
178
+ } else {
179
+ tries.push(`${base}:${italic ? "ital,wght@0,100..900;1,100..900" : "wght@100..900"}${display}`);
180
+ tries.push(`${base}${display}`);
181
+ }
182
+ for (const url of tries) {
183
+ const { status, text } = await fetchText(url);
184
+ if (status === 200) return text;
185
+ }
186
+ throw new Error(`next/font/google: Google Fonts has no "${name}" with ${weights.join(", ") || "these options"} (${tries[0]})`);
187
+ }
188
+
189
+ // downloads one font into the package: its files, its @font-face rules under the package's own family name, a class
190
+ export async function buildFont(family, options, { prefix, outDir, where }) {
191
+ const subsets = asList(options.subsets);
192
+ const css = await googleCss(family, options);
193
+ const key = crypto.createHash("sha1").update(`${family}${JSON.stringify(options)}`).digest("hex").slice(0, 8);
194
+ const slug = `${family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${key}`;
195
+ const fontFamily = `__${prefix.replace(/[^a-zA-Z0-9]/g, "_")}_${family}_${key}`;
196
+ const files = [];
197
+ const faces = [];
198
+ // a variable font answers the same file for every weight: downloaded once
199
+ const downloaded = new Map();
200
+ // Google answers one @font-face per subset, each after a /* subset */ comment
201
+ for (const match of css.matchAll(/(?:\/\*\s*([\w-]+)\s*\*\/\s*)?@font-face\s*\{([^}]*)\}/g)) {
202
+ const [, subset, body] = match;
203
+ if (subsets.length && subset && !subsets.includes(subset)) continue;
204
+ const source = /url\((https:[^)]+)\)/.exec(body)?.[1];
205
+ if (!source) continue;
206
+ let file = downloaded.get(source);
207
+ if (!file) {
208
+ file = `${prefix}-font-${slug}-${files.length + 1}${path.extname(new URL(source).pathname) || ".woff2"}`;
209
+ const response = await fetch(source, { headers: { "User-Agent": CHROME } });
210
+ if (!response.ok) throw new Error(`${where}: could not download ${source} (${response.status})`);
211
+ fs.writeFileSync(path.join(outDir, file), Buffer.from(await response.arrayBuffer()));
212
+ downloaded.set(source, file);
213
+ files.push(file);
214
+ }
215
+ faces.push(`@font-face {${body.replace(/font-family:[^;]+;/, `font-family: '${fontFamily}';`).replace(/url\([^)]+\)/, `url(${file})`)}}`);
216
+ }
217
+ if (!faces.length) throw new Error(`${where}: Google Fonts sent no ${family} files for subsets ${subsets.join(", ") || "(all)"}`);
218
+ const fallback = asList(options.fallback).length ? asList(options.fallback) : ["system-ui", "sans-serif"];
219
+ const stack = [`'${fontFamily}'`, ...fallback].join(", ");
220
+ const className = `znf-${prefix}-${slug}`.replace(/[^a-zA-Z0-9_-]/g, "-");
221
+ const variable = options.variable ? `${className}-variable` : "";
222
+ const rules = [`.${className} { font-family: ${stack}; }`];
223
+ if (variable) rules.push(`.${variable} { ${options.variable}: ${stack}; }`);
224
+ return {
225
+ files,
226
+ css: `/* ${family} (next/font/google, built with the package) */\n${faces.join("\n")}\n${rules.join("\n")}\n`,
227
+ value: { className, style: { fontFamily: stack }, ...(variable ? { variable } : {}) },
228
+ };
229
+ }
230
+
231
+ // replaces next/font/google imports and calls in one module's code
232
+ async function transformFonts(code, id, context) {
233
+ const imports = [...code.matchAll(/import\s*\{([^}]*)\}\s*from\s*["']next\/font\/google["'];?/g)];
234
+ if (!imports.length) return null;
235
+ let output = code;
236
+ for (const match of imports) output = output.replace(match[0], "");
237
+ const names = imports.flatMap((match) =>
238
+ match[1]
239
+ .split(",")
240
+ .map((part) => part.trim())
241
+ .filter(Boolean)
242
+ .map((part) => {
243
+ const [family, local = family] = part.split(/\s+as\s+/).map((item) => item.trim());
244
+ return { family, local };
245
+ })
246
+ );
247
+ for (const { family, local } of names) {
248
+ const call = new RegExp(`(^|[^\\w$.])${local}\\s*\\(`, "g");
249
+ let match;
250
+ while ((match = call.exec(output))) {
251
+ const start = match.index + match[1].length;
252
+ const open = output.indexOf("(", start);
253
+ const close = closingParen(output, open);
254
+ if (close < 0) throw new Error(`${id}: unclosed ${local}( call`);
255
+ const where = `${path.basename(id)} ${family}()`;
256
+ const font = await buildFont(family, readOptions(output.slice(open + 1, close).trim(), where), { ...context, where });
257
+ context.fonts.push(font);
258
+ const value = JSON.stringify(font.value);
259
+ output = output.slice(0, start) + value + output.slice(close + 1);
260
+ call.lastIndex = start + value.length;
261
+ }
262
+ }
263
+ return output;
264
+ }
265
+
266
+ /**
267
+ * The rollup plugin. part: "server" | "browser"; prefix: "<name>-<version>"; outDir: dist; fonts: collects what
268
+ * next/font/google built (files, css), which the build adds to the package's assets and stylesheet.
269
+ */
270
+ export function znodeNext({ part, prefix, outDir, fonts }) {
271
+ return {
272
+ name: "znode-next",
273
+ resolveId(source) {
274
+ if (STORE_MODULES[source] && part === "server") {
275
+ this.error(`"${source}" is a store module for browser parts (src/browser.tsx), not server parts`);
276
+ }
277
+ if (MODULES[source]) return VIRTUAL + source;
278
+ if (source === FONT_MODULE) return null;
279
+ if (source === "next" || source.startsWith("next/")) {
280
+ this.error(`"${source}" is not available to packages. Supported: ${[...Object.keys(MODULES), FONT_MODULE].filter((id) => !STORE_MODULES[id]).join(", ")}`);
281
+ }
282
+ if (source.startsWith("@znode/")) {
283
+ this.error(`"${source}" is not available to packages. Store components a package can import: ${Object.keys(STORE_MODULES).join(", ")}`);
284
+ }
285
+ return null;
286
+ },
287
+ load(id) {
288
+ return id.startsWith(VIRTUAL) ? standIn(id.slice(VIRTUAL.length), part) : null;
289
+ },
290
+ async transform(code, id) {
291
+ if (id.startsWith(VIRTUAL)) return null;
292
+ const output = await transformFonts(code, id, { prefix, outDir, fonts });
293
+ return output === null ? null : { code: output, map: null };
294
+ },
295
+ };
296
+ }
297
+
298
+ export const SUPPORTED_MODULES = MODULES;