@protobase/cli 0.11.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.
@@ -17,9 +17,10 @@ const componentImports = (source) => {
17
17
  namespaces
18
18
  };
19
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");
20
21
  /**
21
22
  * The names a module declares with `component('Name')` from @protobase/layout, read from its source: the config does
22
- * not run at build time. A name that is not a string literal is not seen.
23
+ * not run at build time. A name that is not a string literal is not seen, nor one a plugin provides.
23
24
  */
24
25
  const declaredComponents = (code, file) => {
25
26
  if (!code.includes(layoutModule)) return [];
@@ -29,7 +30,7 @@ const declaredComponents = (code, file) => {
29
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);
30
31
  const names = [];
31
32
  const visit = (node) => {
32
- if (ts.isCallExpression(node) && isComponent(node.expression) && node.arguments[0] && ts.isStringLiteralLike(node.arguments[0])) names.push(node.arguments[0].text);
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);
33
34
  ts.forEachChild(node, visit);
34
35
  };
35
36
  visit(source);
@@ -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),
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.11.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 };
@@ -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.11.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.11.0",
38
- "@protobase/query": "0.11.0",
39
- "@protobase/schema": "0.11.0",
40
- "@protobase/server": "0.11.0",
41
- "@protobase/ui": "0.11.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",