@protobase/cli 0.10.0 → 0.11.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.
@@ -0,0 +1,49 @@
1
+ import { ts } from "ts-morph";
2
+ //#region src/build/component-names.ts
3
+ const layoutModule = "@protobase/layout";
4
+ const componentImports = (source) => {
5
+ const named = /* @__PURE__ */ new Set();
6
+ const namespaces = /* @__PURE__ */ new Set();
7
+ for (const statement of source.statements) {
8
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== layoutModule) continue;
9
+ const bindings = statement.importClause?.namedBindings;
10
+ if (bindings && ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text);
11
+ if (bindings && ts.isNamedImports(bindings)) {
12
+ for (const element of bindings.elements) if ((element.propertyName ?? element.name).text === "component") named.add(element.name.text);
13
+ }
14
+ }
15
+ return {
16
+ named,
17
+ namespaces
18
+ };
19
+ };
20
+ const fromPlugin = (options) => options !== void 0 && ts.isObjectLiteralExpression(options) && options.properties.some((property) => (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && property.name.getText() === "plugin");
21
+ /**
22
+ * The names a module declares with `component('Name')` from @protobase/layout, read from its source: the config does
23
+ * not run at build time. A name that is not a string literal is not seen, nor one a plugin provides.
24
+ */
25
+ const declaredComponents = (code, file) => {
26
+ if (!code.includes(layoutModule)) return [];
27
+ const source = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true, /\.[jt]sx$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
28
+ const { named, namespaces } = componentImports(source);
29
+ if (named.size === 0 && namespaces.size === 0) return [];
30
+ const isComponent = (callee) => ts.isIdentifier(callee) && named.has(callee.text) || ts.isPropertyAccessExpression(callee) && callee.name.text === "component" && ts.isIdentifier(callee.expression) && namespaces.has(callee.expression.text);
31
+ const names = [];
32
+ const visit = (node) => {
33
+ if (ts.isCallExpression(node) && isComponent(node.expression) && node.arguments[0] && ts.isStringLiteralLike(node.arguments[0]) && !fromPlugin(node.arguments[1])) names.push(node.arguments[0].text);
34
+ ts.forEachChild(node, visit);
35
+ };
36
+ visit(source);
37
+ return names;
38
+ };
39
+ /** Adds the components every bundled module declares to `into`, for the check that the project's UI exports each. */
40
+ const collectComponents = (into) => ({
41
+ name: "protobase-component-names",
42
+ enforce: "pre",
43
+ transform(code, id) {
44
+ if (!/\.[cm]?[jt]sx?$/.test(id.replace(/\?.*$/, ""))) return void 0;
45
+ for (const name of declaredComponents(code, id)) into.add(name);
46
+ }
47
+ });
48
+ //#endregion
49
+ export { collectComponents, declaredComponents };
@@ -1,5 +1,6 @@
1
1
  import { hostModuleIds } from "../serve/host-module-ids.js";
2
2
  import { extensionImports } from "../project/extensions.js";
3
+ import { collectComponents } from "./component-names.js";
3
4
  import { bundleEntryCode } from "./entry-code.js";
4
5
  import { layoutCheck } from "./layout-check.js";
5
6
  import { findPackageDir, packageName } from "./packages/package-dir.js";
@@ -52,11 +53,12 @@ const servedImports = (native) => {
52
53
  }
53
54
  };
54
55
  };
55
- const buildConfigBundle = async ({ projectDir, outFile, bun, extensions = [] }) => {
56
+ const buildConfigBundle = async ({ projectDir, outFile, bun, extensions = [], components = /* @__PURE__ */ new Set() }) => {
56
57
  const file = path.join(projectDir, "protobase-bundle-entry.js");
57
58
  const native = /* @__PURE__ */ new Map();
58
59
  const plugins = [
59
60
  layoutCheck(),
61
+ collectComponents(components),
60
62
  extensionImports(projectDir, extensions),
61
63
  servedImports(native),
62
64
  entry(file, bundleEntryCode(projectDir, extensions))
@@ -3,7 +3,7 @@ import { buildConfigBundle } from "./config-bundle.js";
3
3
  import { protobaseVersion } from "../version/version.js";
4
4
  import { copyPackages } from "./packages/copy-packages.js";
5
5
  import { buildUiBundle } from "./ui-bundle.js";
6
- import { checkUiMerge } from "./ui-check.js";
6
+ import { checkUi } from "./ui-check.js";
7
7
  import { existsSync } from "node:fs";
8
8
  import path from "node:path";
9
9
  import { readFile, rm, writeFile } from "node:fs/promises";
@@ -20,13 +20,15 @@ const removeOwnNodeModules = async (outDir) => {
20
20
  };
21
21
  const buildDeployBundle = async ({ projectDir, outDir, bun, extensions = [] }) => {
22
22
  await removeOwnNodeModules(outDir);
23
- if (extensions.some((extension) => extension.ui)) await checkUiMerge(projectDir, extensions);
23
+ const components = /* @__PURE__ */ new Set();
24
24
  const native = await buildConfigBundle({
25
25
  projectDir,
26
26
  outFile: path.join(outDir, bundleLayout.server),
27
27
  bun,
28
- extensions
28
+ extensions,
29
+ components
29
30
  });
31
+ await checkUi(projectDir, extensions, components);
30
32
  const packages = native.length > 0 ? await copyPackages(native, path.join(outDir, bundleNodeModules)) : [];
31
33
  await buildUiBundle({
32
34
  outDir: path.join(outDir, bundleLayout.public),
@@ -0,0 +1,139 @@
1
+ import { uiPackageDir } from "../project/admin-app.js";
2
+ import { findPackageDirSync, readPackageJson } from "./packages/package-dir.js";
3
+ import { sharedModules } from "../project/shared-modules.js";
4
+ import { createRequire } from "node:module";
5
+ import { existsSync } from "node:fs";
6
+ import path from "node:path";
7
+ import { build } from "vite";
8
+ import { writeFile } from "node:fs/promises";
9
+ import tailwindcss from "@tailwindcss/vite";
10
+ import react from "@vitejs/plugin-react";
11
+ import { inRange } from "@protobase/schema";
12
+ //#region src/build/plugin-build.ts
13
+ /** The module a plugin's components and action handlers are exported from, as a project's are. */
14
+ const pluginEntryFile = "protobase.ui.tsx";
15
+ /** What `build-plugin` writes beside the plugin's files, which the app reads first. */
16
+ const pluginManifestFile = "plugin.json";
17
+ const tailwindDir = path.dirname(createRequire(path.join(uiPackageDir, "package.json")).resolve("tailwindcss/package.json"));
18
+ /**
19
+ * What the plugin is built for, from the @protobase/ui installed for it: its plugin API, and Protobase from its version
20
+ * on, unless the plugin's package.json names other versions in `engines.protobase`. Also that version's theme, which
21
+ * the plugin's stylesheet draws with.
22
+ */
23
+ const pluginTarget = async (pluginDir, own) => {
24
+ const uiDir = findPackageDirSync("@protobase/ui", pluginDir);
25
+ if (!uiDir) throw new Error(`@protobase/ui is not installed for ${pluginDir}; add it to the plugin's dependencies, as the version it is built for`);
26
+ const ui = await readPackageJson(uiDir);
27
+ const api = ui.protobase?.pluginApi;
28
+ const theme = ui.exports?.["./theme.css"];
29
+ if (api === void 0 || typeof theme !== "string") throw new Error(`@protobase/ui ${ui.version} loads no plugins; install a version that does`);
30
+ const protobase = own.engines?.protobase ?? `>=${ui.version}`;
31
+ const fits = inRange(ui.version ?? "", protobase);
32
+ if (fits === void 0) throw new Error(`engines.protobase "${protobase}" is not a range the app reads; write comparators such as >=0.12.0 <1.0.0`);
33
+ if (!fits) throw new Error(`engines.protobase "${protobase}" leaves out @protobase/ui ${ui.version}, which the plugin is built with`);
34
+ return {
35
+ api,
36
+ protobase,
37
+ theme: path.join(uiDir, theme)
38
+ };
39
+ };
40
+ const pluginImports = () => ({
41
+ name: "protobase-plugin-imports",
42
+ enforce: "pre",
43
+ resolveId(id) {
44
+ if (sharedModules.includes(id)) return {
45
+ id,
46
+ external: true
47
+ };
48
+ if (sharedModules.some((name) => id.startsWith(`${name}/`))) this.error(`${id} is not shared with plugins, and a copy of its own would not work with the app's; a plugin imports ${sharedModules.join(", ")} by those names alone`);
49
+ }
50
+ });
51
+ const styleCode = (pluginDir, theme) => [
52
+ "@layer theme, base, components, utilities;",
53
+ `@import ${JSON.stringify(path.join(tailwindDir, "theme.css"))} layer(theme) theme(reference);`,
54
+ `@reference ${JSON.stringify(theme)};`,
55
+ `@import ${JSON.stringify(path.join(tailwindDir, "utilities.css"))} layer(utilities) source(none);`,
56
+ `@source ${JSON.stringify(pluginDir)};`,
57
+ ""
58
+ ].join("\n");
59
+ const pluginStyles = (file, code) => ({
60
+ name: "protobase-plugin-styles",
61
+ resolveId: (id) => id === file ? id : void 0,
62
+ load: (id) => id === file ? code : void 0
63
+ });
64
+ const outputOf = (result) => {
65
+ if (Array.isArray(result)) return result[0];
66
+ if ("output" in result) return result;
67
+ throw new Error("The plugin build did not finish");
68
+ };
69
+ /** The output folder must not hold the plugin's own files, which emptying it would delete. */
70
+ const checkOutDir = (pluginDir, outDir) => {
71
+ const relative = path.relative(outDir, pluginDir);
72
+ if (!relative.startsWith("..") && !path.isAbsolute(relative)) throw new Error(`--out ${outDir} holds the plugin's own files; build into a folder inside it or elsewhere, such as ${path.join(pluginDir, "dist")}`);
73
+ };
74
+ /**
75
+ * Builds the plugin in `pluginDir` into `outDir`: its protobase.ui.tsx as an ES module (and its chunks) that imports the
76
+ * shared modules by name, its stylesheet, and plugin.json naming both. The app loads it from the plugin.json's URL.
77
+ */
78
+ const buildPlugin = async ({ pluginDir, outDir, name }) => {
79
+ const entry = path.join(pluginDir, pluginEntryFile);
80
+ if (!existsSync(entry)) throw new Error(`There is no ${pluginEntryFile} in ${pluginDir}; a plugin exports its components and action handlers from it`);
81
+ checkOutDir(pluginDir, outDir);
82
+ const own = existsSync(path.join(pluginDir, "package.json")) ? await readPackageJson(pluginDir) : {};
83
+ const pluginName = (name ?? own.name ?? path.basename(pluginDir)).trim();
84
+ if (pluginName === "") throw new Error("The plugin needs a name: give its package.json a name, or pass --name");
85
+ const target = await pluginTarget(pluginDir, own);
86
+ const styles = path.join(pluginDir, "protobase-plugin.css");
87
+ const nodeEnv = process.env.NODE_ENV;
88
+ process.env.NODE_ENV = "production";
89
+ const result = await build({
90
+ configFile: false,
91
+ root: pluginDir,
92
+ base: "./",
93
+ mode: "production",
94
+ publicDir: false,
95
+ logLevel: "warn",
96
+ plugins: [
97
+ pluginImports(),
98
+ pluginStyles(styles, styleCode(pluginDir, target.theme)),
99
+ react(),
100
+ tailwindcss()
101
+ ],
102
+ build: {
103
+ outDir,
104
+ emptyOutDir: true,
105
+ modulePreload: false,
106
+ rolldownOptions: {
107
+ input: {
108
+ index: entry,
109
+ styles
110
+ },
111
+ preserveEntrySignatures: "strict",
112
+ output: {
113
+ entryFileNames: "assets/[name]-[hash].js",
114
+ chunkFileNames: "assets/[name]-[hash].js",
115
+ assetFileNames: "assets/[name]-[hash][extname]"
116
+ }
117
+ }
118
+ }
119
+ }).finally(() => {
120
+ if (nodeEnv === void 0) delete process.env.NODE_ENV;
121
+ else process.env.NODE_ENV = nodeEnv;
122
+ });
123
+ const output = outputOf(result).output;
124
+ const module = output.find((file) => file.type === "chunk" && file.isEntry && file.facadeModuleId === entry);
125
+ const css = output.find((file) => file.type === "asset" && file.fileName.endsWith(".css"));
126
+ if (!module || !css) throw new Error("The plugin build wrote no module or no stylesheet");
127
+ const manifest = {
128
+ name: pluginName,
129
+ ...own.version && { version: own.version },
130
+ api: target.api,
131
+ protobase: target.protobase,
132
+ entry: module.fileName,
133
+ css: css.fileName
134
+ };
135
+ await writeFile(path.join(outDir, pluginManifestFile), `${JSON.stringify(manifest, null, 2)}\n`);
136
+ return manifest;
137
+ };
138
+ //#endregion
139
+ export { buildPlugin, pluginEntryFile, pluginManifestFile };
@@ -1,6 +1,7 @@
1
1
  import { adminAppDir } from "../project/admin-app.js";
2
2
  import { extensionImports, extensionStyles } from "../project/extensions.js";
3
3
  import { projectUiPlugin } from "../project/project-ui.js";
4
+ import { sharedModulesPlugin } from "../project/shared-modules.js";
4
5
  import { build } from "vite";
5
6
  import tailwindcss from "@tailwindcss/vite";
6
7
  import react from "@vitejs/plugin-react";
@@ -16,6 +17,7 @@ const buildUiBundle = async ({ outDir, projectDir, extensions = [] }) => {
16
17
  publicDir: false,
17
18
  logLevel: "warn",
18
19
  plugins: [
20
+ sharedModulesPlugin(),
19
21
  projectUiPlugin(projectDir, extensions),
20
22
  ...projectDir === void 0 ? [] : [extensionImports(projectDir, extensions)],
21
23
  extensionStyles(extensions),
@@ -1,24 +1,42 @@
1
1
  import { extensionImports } from "../project/extensions.js";
2
2
  import { viteBundle } from "./vite-bundle.js";
3
- import { mergedUiFile, projectUiPlugin } from "../project/project-ui.js";
3
+ import { projectUiFile, projectUiId, projectUiPlugin } from "../project/project-ui.js";
4
+ import { existsSync } from "node:fs";
4
5
  import path from "node:path";
5
6
  import { mkdtemp, rm } from "node:fs/promises";
6
7
  import { pathToFileURL } from "node:url";
7
8
  import { tmpdir } from "node:os";
8
9
  //#region src/build/ui-check.ts
9
- const checkUiMerge = async (projectDir, extensions) => {
10
+ const checkFile = (projectDir) => path.join(projectDir, "protobase-ui-check.js");
11
+ const checkCode = [
12
+ `import * as ui from '${projectUiId}'`,
13
+ `import { mergeUi, uiFromModule } from '@protobase/ui'`,
14
+ "export default Object.keys(mergeUi(uiFromModule(ui)).components ?? {})",
15
+ ""
16
+ ].join("\n");
17
+ const checkEntry = (file) => ({
18
+ name: "protobase-ui-check",
19
+ resolveId: (id) => id === file ? `\0${file}` : void 0,
20
+ load: (id) => id === `\0${file}` ? checkCode : void 0
21
+ });
22
+ const registeredComponents = async (projectDir, extensions) => {
23
+ if (!existsSync(projectUiFile(projectDir)) && !extensions.some((extension) => extension.ui)) return [];
10
24
  const scratch = await mkdtemp(path.join(tmpdir(), "protobase-ui-check-"));
11
- const outFile = path.join(scratch, "ui-check.js");
25
+ const outFile = path.join(scratch, "ui-check.mjs");
12
26
  try {
13
- const plugins = [projectUiPlugin(projectDir, extensions), extensionImports(projectDir, extensions)];
27
+ const plugins = [
28
+ checkEntry(checkFile(projectDir)),
29
+ projectUiPlugin(projectDir, extensions),
30
+ extensionImports(projectDir, extensions)
31
+ ];
14
32
  await viteBundle({
15
33
  root: projectDir,
16
- entry: mergedUiFile(projectDir),
34
+ entry: checkFile(projectDir),
17
35
  outFile,
18
36
  plugins,
19
37
  logLevel: "error"
20
38
  });
21
- await import(pathToFileURL(outFile).href);
39
+ return (await import(pathToFileURL(outFile).href)).default;
22
40
  } finally {
23
41
  await rm(scratch, {
24
42
  recursive: true,
@@ -26,5 +44,17 @@ const checkUiMerge = async (projectDir, extensions) => {
26
44
  });
27
45
  }
28
46
  };
47
+ /**
48
+ * Fails the build, instead of the app in the browser, when two UI configs define the same component or action
49
+ * handler, or when the config declares a component with `component('Name')` that protobase.ui.tsx does not export.
50
+ * It loads only browser code, which needs no secrets; nothing is rendered.
51
+ */
52
+ const checkUi = async (projectDir, extensions, declared) => {
53
+ if (declared.size === 0 && !extensions.some((extension) => extension.ui)) return;
54
+ const registered = new Set(await registeredComponents(projectDir, extensions));
55
+ const missing = [...declared].filter((name) => !registered.has(name)).sort();
56
+ if (missing.length === 0) return;
57
+ throw new Error(`The config declares ${missing.map((name) => `component('${name}')`).join(", ")}, but protobase.ui.tsx exports no component by ${missing.length === 1 ? "that name" : "those names"}. Export each from its own file and re-export it, such as: export { ${missing[0]} } from './ui/${missing[0]}'`);
58
+ };
29
59
  //#endregion
30
- export { checkUiMerge };
60
+ export { checkUi };
package/dist/dev/run.js CHANGED
@@ -4,6 +4,7 @@ import { extensionImports, extensionStyles } from "../project/extensions.js";
4
4
  import { moduleFile } from "../module-file.js";
5
5
  import { layoutCheck } from "../build/layout-check.js";
6
6
  import { projectUiPlugin } from "../project/project-ui.js";
7
+ import { sharedModulesPlugin } from "../project/shared-modules.js";
7
8
  import { checkDatabase } from "./check-database.js";
8
9
  import { stopDevJobs } from "./dev-jobs.js";
9
10
  import { existsSync } from "node:fs";
@@ -71,6 +72,7 @@ const runDev = async (options, out) => {
71
72
  plugins: [
72
73
  reloadOnProjectChange([options.projectDir, ...extensions.map((extension) => extension.dir)]),
73
74
  stopJobsOnClose,
75
+ sharedModulesPlugin(),
74
76
  projectUiPlugin(options.projectDir, extensions),
75
77
  extensionImports(options.projectDir, extensions),
76
78
  extensionStyles(extensions),
package/dist/main.js CHANGED
@@ -3,6 +3,7 @@ import { registerAuth } from "./auth/register.js";
3
3
  import { manifestFile } from "./bundle/manifest.js";
4
4
  import { resolveExtensions } from "./project/extensions.js";
5
5
  import { buildDeployBundle } from "./build/deploy-bundle.js";
6
+ import { buildPlugin, pluginManifestFile } from "./build/plugin-build.js";
6
7
  import { buildServeRuntime } from "./build/serve-runtime.js";
7
8
  import { resolveConnectionUrl } from "./db/connect.js";
8
9
  import { parseHosts } from "./dev/hosts.js";
@@ -76,6 +77,17 @@ program.command("build").description("Build the deploy bundle of the project in
76
77
  const packages = manifest.nodeModules ? `, ${manifest.nodeModules}/` : "";
77
78
  out(`built ${opts.out}: ${manifest.server}${packages}, ${manifest.public}/ and ${manifestFile} (api ${manifest.api.join(", ")})\n`);
78
79
  });
80
+ program.command("build-plugin").description("Build a plugin the admin app loads at runtime: its protobase.ui.tsx as one ES module, its stylesheet and plugin.json").argument("[dir]", "the plugin folder, holding protobase.ui.tsx", ".").option("--out <dir>", "output folder (default: dist in the plugin folder)").option("--name <name>", "the plugin's name (default: its package.json name)").action(async (dir, opts) => {
81
+ const pluginDir = path.resolve(dir);
82
+ const outDir = path.resolve(opts.out ?? path.join(dir, "dist"));
83
+ const manifest = await buildPlugin({
84
+ pluginDir,
85
+ outDir,
86
+ ...opts.name && { name: opts.name }
87
+ });
88
+ const shown = path.relative(process.cwd(), outDir);
89
+ out(`built ${shown.startsWith("..") ? outDir : shown || "."}: ${pluginManifestFile}, ${manifest.entry} and ${manifest.css} (${manifest.name}, plugin API ${manifest.api}, Protobase ${manifest.protobase})\n`);
90
+ });
79
91
  program.command("build-serve").description("Bundle the serve runtime into one file for Bun: bun --no-install protobase-serve.js <bundle.js>").option("--out <file>", "output file", "dist/protobase-serve.js").option("--bun", "have Bun's bundler write the file (needs bun on PATH)", false).action(async (opts) => {
80
92
  await buildServeRuntime({
81
93
  outFile: path.resolve(opts.out),
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.10.0";
2
+ var version = "0.11.1";
3
3
  //#endregion
4
4
  export { version };
@@ -6,4 +6,4 @@ const uiPackageDir = path.resolve(path.dirname(uiEntry), "..");
6
6
  const adminAppDir = path.join(path.dirname(uiEntry), "app");
7
7
  const adminAppMain = `main${path.extname(uiEntry) === ".ts" ? ".tsx" : ".js"}`;
8
8
  //#endregion
9
- export { adminAppDir, adminAppMain, uiPackageDir };
9
+ export { adminAppDir, adminAppMain, uiEntry, uiPackageDir };
@@ -10,11 +10,11 @@ const mergedUiCode = (projectDir, extensions) => {
10
10
  const own = projectUiFile(projectDir);
11
11
  const hasOwn = existsSync(own);
12
12
  return [
13
- `import { mergeUi } from '@protobase/ui'`,
13
+ `import { mergeUi, uiFromModule } from '@protobase/ui'`,
14
14
  ...files.map((file, i) => `import * as extension${i} from ${JSON.stringify(file)}`),
15
15
  ...hasOwn ? [`import * as project from ${JSON.stringify(own)}`] : [],
16
16
  "const named = (ui, file) => ({ ...ui, name: ui.name ?? file })",
17
- `export default mergeUi(${[...files.map((file, i) => `named(extension${i}.default ?? {}, ${JSON.stringify(file)})`), ...hasOwn ? [`named(project.default ?? {}, "protobase.ui.tsx")`] : []].join(", ")})`,
17
+ `export default mergeUi(${[...files.map((file, i) => `named(uiFromModule(extension${i}), ${JSON.stringify(file)})`), ...hasOwn ? [`named(uiFromModule(project), "protobase.ui.tsx")`] : []].join(", ")})`,
18
18
  ""
19
19
  ].join("\n");
20
20
  };
@@ -0,0 +1,88 @@
1
+ import { uiEntry, uiPackageDir } from "./admin-app.js";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ //#region src/project/shared-modules.ts
5
+ /**
6
+ * The modules the admin app hands to plugins: a plugin imports them by these names and runs on the app's own copies,
7
+ * so there is one React and one @protobase/ui. Adding or removing one changes the plugin API (see `pluginApi` in
8
+ * @protobase/ui's package.json).
9
+ */
10
+ const sharedModules = [
11
+ "react",
12
+ "react/jsx-runtime",
13
+ "react-dom",
14
+ "@protobase/ui"
15
+ ];
16
+ const facadePrefix = "\0protobase-shared:";
17
+ const facadeId = (name) => `${facadePrefix}${name}`;
18
+ /** The entry a production build gives a shared module, which names its file: `shared-react-jsx-runtime-<hash>.js`. */
19
+ const entryName = (name) => `shared-${name.replace(/^@/, "").replace(/\W+/g, "-")}`;
20
+ const commonJs = /* @__PURE__ */ new Set([
21
+ "react",
22
+ "react/jsx-runtime",
23
+ "react-dom"
24
+ ]);
25
+ const isSharedModule = (name) => sharedModules.includes(name);
26
+ const identifier = /^[A-Za-z_$][\w$]*$/;
27
+ /**
28
+ * The module standing for a shared one in the import map. A CommonJS package's is an ES module exporting each name the
29
+ * package has, read with the CLI's copy of it, so a plugin's `import { useState } from 'react'` links; it imports the
30
+ * package as the app does, so both reach the one copy. @protobase/ui's re-exports the entry the app is built from.
31
+ */
32
+ const facadeCode = (name, require = createRequire(path.join(uiPackageDir, "package.json"))) => {
33
+ if (!commonJs.has(name)) return `export * from ${JSON.stringify(uiEntry)}\n`;
34
+ const names = Object.keys(require(name)).filter((key) => key !== "default" && identifier.test(key));
35
+ return `import m from ${JSON.stringify(name)}\nexport default m\nexport const { ${names.join(", ")} } = m\n`;
36
+ };
37
+ /** The import map, as the first tag of `<head>`: a plugin's bare imports of the shared modules resolve before any module loads. */
38
+ const importMapTag = (imports) => ({
39
+ tag: "script",
40
+ attrs: { type: "importmap" },
41
+ children: JSON.stringify({ imports }),
42
+ injectTo: "head-prepend"
43
+ });
44
+ const moduleUrls = (base, context) => {
45
+ if (!context.bundle) return Object.fromEntries(sharedModules.map((name) => [name, `${base}@id/__x00__${facadeId(name).slice(1)}`]));
46
+ const chunks = Object.values(context.bundle).flatMap((output) => output.type === "chunk" && output.isEntry ? [output] : []);
47
+ return Object.fromEntries(sharedModules.map((name) => {
48
+ const chunk = chunks.find((candidate) => candidate.facadeModuleId === facadeId(name));
49
+ if (!chunk) throw new Error(`The build has no entry for the shared module ${name}`);
50
+ return [name, `${base}${chunk.fileName}`];
51
+ }));
52
+ };
53
+ /**
54
+ * Shares the modules plugins import with the admin app: an entry per shared module beside index.html in a build, its
55
+ * facade module, and the import map in index.html, for `protobase build` and `protobase dev` alike.
56
+ */
57
+ const sharedModulesPlugin = () => {
58
+ let base = "/";
59
+ return {
60
+ name: "protobase-shared-modules",
61
+ config: (config, { command }) => {
62
+ if (command !== "build") return void 0;
63
+ const root = config.root ?? process.cwd();
64
+ return { build: { rolldownOptions: {
65
+ input: {
66
+ index: path.join(root, "index.html"),
67
+ ...Object.fromEntries(sharedModules.map((name) => [entryName(name), facadeId(name)]))
68
+ },
69
+ preserveEntrySignatures: "exports-only"
70
+ } } };
71
+ },
72
+ configResolved: (config) => {
73
+ base = config.base;
74
+ },
75
+ resolveId: (id) => id.startsWith(facadePrefix) ? id : void 0,
76
+ load: (id) => {
77
+ if (!id.startsWith(facadePrefix)) return void 0;
78
+ const name = id.slice(18);
79
+ return isSharedModule(name) ? facadeCode(name) : void 0;
80
+ },
81
+ transformIndexHtml: {
82
+ order: "post",
83
+ handler: (_html, context) => [importMapTag(moduleUrls(base, context))]
84
+ }
85
+ };
86
+ };
87
+ //#endregion
88
+ export { entryName, facadeCode, facadeId, importMapTag, sharedModules, sharedModulesPlugin };
@@ -1,6 +1,6 @@
1
1
  import * as server from "@protobase/server";
2
- import * as postgres$1 from "postgres";
3
2
  import * as schema from "@protobase/schema";
3
+ import * as postgres$1 from "postgres";
4
4
  import * as nodeServer from "@hono/node-server";
5
5
  import * as pg$1 from "pg";
6
6
  import * as kysely from "kysely";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "The protobase command: scaffold, doctor, dev, build and serve a Protobase project.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -34,11 +34,11 @@
34
34
  "dependencies": {
35
35
  "@hono/node-server": "^2.1.3",
36
36
  "@hono/vite-dev-server": "^0.26.1",
37
- "@protobase/layout": "0.10.0",
38
- "@protobase/query": "0.10.0",
39
- "@protobase/schema": "0.10.0",
40
- "@protobase/server": "0.10.0",
41
- "@protobase/ui": "0.10.0",
37
+ "@protobase/layout": "0.11.1",
38
+ "@protobase/query": "0.11.1",
39
+ "@protobase/schema": "0.11.1",
40
+ "@protobase/server": "0.11.1",
41
+ "@protobase/ui": "0.11.1",
42
42
  "@scalar/hono-api-reference": "^0.12.10",
43
43
  "@tailwindcss/vite": "^4.3.3",
44
44
  "@vitejs/plugin-react": "^6.1.2",