@protobase/cli 0.11.1 → 0.12.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/dist/build/bundle-plugins.js +55 -0
- package/dist/build/config-bundle.js +3 -3
- package/dist/build/deploy-bundle.js +37 -7
- package/dist/build/plugin-build.js +31 -15
- package/dist/build/plugin-fields.js +65 -0
- package/dist/build/serve-runtime.js +1 -1
- package/dist/build/shell.js +91 -0
- package/dist/build/theme.js +87 -0
- package/dist/build/ui-check.js +2 -2
- package/dist/bundle/manifest.js +15 -5
- package/dist/dev/run.js +4 -2
- package/dist/main.js +12 -4
- package/dist/package.js +1 -1
- package/dist/project/project-admin.js +7 -2
- package/dist/project/theme-link.js +43 -0
- package/dist/serve/bundle-ui.js +7 -0
- package/dist/serve/lifecycle.js +2 -2
- package/dist/serve/serve-bundle.js +6 -1
- package/dist/serve/serve-dir.js +9 -2
- package/dist/serve/served-shell.js +30 -0
- package/dist/serve/static-site.js +11 -7
- package/package.json +7 -6
- package/dist/build/ui-bundle.js +0 -38
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { projectUiFile } from "../project/project-ui.js";
|
|
2
|
+
import { readPackageJson } from "./packages/package-dir.js";
|
|
3
|
+
import { buildPlugin, pluginManifestFile } from "./plugin-build.js";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
/** Where a bundle's plugins go, inside its `public` folder. */
|
|
7
|
+
const pluginsFolder = "plugins";
|
|
8
|
+
const extensionName = async (extension) => {
|
|
9
|
+
return (existsSync(path.join(extension.dir, "package.json")) ? await readPackageJson(extension.dir) : {}).name ?? path.basename(extension.dir);
|
|
10
|
+
};
|
|
11
|
+
const folderOf = (name) => name.replace(/^@/, "").replace(/[^\w.-]+/g, "-");
|
|
12
|
+
const sources = async (projectDir, extensions) => {
|
|
13
|
+
const own = existsSync(projectUiFile(projectDir)) ? [{
|
|
14
|
+
dir: projectDir,
|
|
15
|
+
name: "app"
|
|
16
|
+
}] : [];
|
|
17
|
+
const all = [...await Promise.all(extensions.filter((extension) => extension.ui).map(async (extension) => ({
|
|
18
|
+
dir: extension.dir,
|
|
19
|
+
name: await extensionName(extension)
|
|
20
|
+
}))), ...own];
|
|
21
|
+
const seen = /* @__PURE__ */ new Map();
|
|
22
|
+
for (const source of all) {
|
|
23
|
+
const other = seen.get(folderOf(source.name));
|
|
24
|
+
if (other) throw new Error(`${other} and ${source.dir} would both be the plugin ${folderOf(source.name)}; give the extension's package.json another name`);
|
|
25
|
+
seen.set(folderOf(source.name), source.dir);
|
|
26
|
+
}
|
|
27
|
+
return all;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Builds the project's protobase.ui.tsx and each extension's as a plugin under `publicDir`/plugins/<name>/, as
|
|
31
|
+
* `protobase build-plugin` builds one: the app loads them like any other, from what `/meta` names.
|
|
32
|
+
*/
|
|
33
|
+
const buildBundlePlugins = async ({ projectDir, publicDir, extensions, ignore }) => {
|
|
34
|
+
const plugins = [];
|
|
35
|
+
for (const source of await sources(projectDir, extensions)) {
|
|
36
|
+
const folder = path.join(pluginsFolder, folderOf(source.name));
|
|
37
|
+
const manifest = await buildPlugin({
|
|
38
|
+
pluginDir: source.dir,
|
|
39
|
+
outDir: path.join(publicDir, folder),
|
|
40
|
+
name: source.name,
|
|
41
|
+
projectDir,
|
|
42
|
+
ignore
|
|
43
|
+
});
|
|
44
|
+
plugins.push({
|
|
45
|
+
name: manifest.name,
|
|
46
|
+
path: `${folder}/${pluginManifestFile}`,
|
|
47
|
+
api: manifest.api,
|
|
48
|
+
protobase: manifest.protobase,
|
|
49
|
+
...manifest.fields && { fields: manifest.fields }
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return plugins;
|
|
53
|
+
};
|
|
54
|
+
//#endregion
|
|
55
|
+
export { buildBundlePlugins, pluginsFolder };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { findPackageDir, packageName } from "./packages/package-dir.js";
|
|
2
2
|
import { extensionImports } from "../project/extensions.js";
|
|
3
|
+
import { viteBundle } from "./vite-bundle.js";
|
|
4
|
+
import { hostModuleIds } from "../serve/host-module-ids.js";
|
|
3
5
|
import { collectComponents } from "./component-names.js";
|
|
4
6
|
import { bundleEntryCode } from "./entry-code.js";
|
|
5
7
|
import { layoutCheck } from "./layout-check.js";
|
|
6
|
-
import { findPackageDir, packageName } from "./packages/package-dir.js";
|
|
7
8
|
import { hostPackages, hostVersionProblem } from "./packages/host-packages.js";
|
|
8
9
|
import { isNativePackage } from "./packages/native-package.js";
|
|
9
|
-
import { viteBundle } from "./vite-bundle.js";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
//#region src/build/config-bundle.ts
|
|
12
12
|
const isHostModule = (id) => hostModuleIds.includes(id);
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { bundleLayout, bundleNodeModules, manifestFile } from "../bundle/manifest.js";
|
|
2
|
+
import { buildBundlePlugins } from "./bundle-plugins.js";
|
|
2
3
|
import { buildConfigBundle } from "./config-bundle.js";
|
|
3
4
|
import { protobaseVersion } from "../version/version.js";
|
|
4
5
|
import { copyPackages } from "./packages/copy-packages.js";
|
|
5
|
-
import {
|
|
6
|
+
import { buildTheme } from "./theme.js";
|
|
7
|
+
import { shellForBuild, shellManifestFile } from "./shell.js";
|
|
6
8
|
import { checkUi } from "./ui-check.js";
|
|
7
9
|
import { existsSync } from "node:fs";
|
|
8
10
|
import path from "node:path";
|
|
9
|
-
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
11
|
+
import { cp, readFile, rm, writeFile } from "node:fs/promises";
|
|
10
12
|
//#region src/build/deploy-bundle.ts
|
|
11
13
|
const removeOwnNodeModules = async (outDir) => {
|
|
12
14
|
const dir = path.join(outDir, bundleNodeModules);
|
|
@@ -18,7 +20,26 @@ const removeOwnNodeModules = async (outDir) => {
|
|
|
18
20
|
force: true
|
|
19
21
|
});
|
|
20
22
|
};
|
|
21
|
-
const
|
|
23
|
+
const copyShell = async (publicDir) => {
|
|
24
|
+
const { dir, shell, scratch } = await shellForBuild();
|
|
25
|
+
try {
|
|
26
|
+
await rm(publicDir, {
|
|
27
|
+
recursive: true,
|
|
28
|
+
force: true
|
|
29
|
+
});
|
|
30
|
+
await cp(dir, publicDir, {
|
|
31
|
+
recursive: true,
|
|
32
|
+
filter: (file) => path.basename(file) !== shellManifestFile
|
|
33
|
+
});
|
|
34
|
+
} finally {
|
|
35
|
+
if (scratch) await rm(dir, {
|
|
36
|
+
recursive: true,
|
|
37
|
+
force: true
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return shell;
|
|
41
|
+
};
|
|
42
|
+
const buildDeployBundle = async ({ projectDir, outDir, bun, extensions = [], onWarning }) => {
|
|
22
43
|
await removeOwnNodeModules(outDir);
|
|
23
44
|
const components = /* @__PURE__ */ new Set();
|
|
24
45
|
const native = await buildConfigBundle({
|
|
@@ -30,16 +51,25 @@ const buildDeployBundle = async ({ projectDir, outDir, bun, extensions = [] }) =
|
|
|
30
51
|
});
|
|
31
52
|
await checkUi(projectDir, extensions, components);
|
|
32
53
|
const packages = native.length > 0 ? await copyPackages(native, path.join(outDir, bundleNodeModules)) : [];
|
|
33
|
-
|
|
34
|
-
|
|
54
|
+
const publicDir = path.join(outDir, bundleLayout.public);
|
|
55
|
+
const shell = await copyShell(publicDir);
|
|
56
|
+
const plugins = await buildBundlePlugins({
|
|
35
57
|
projectDir,
|
|
36
|
-
|
|
58
|
+
publicDir,
|
|
59
|
+
extensions,
|
|
60
|
+
ignore: [outDir]
|
|
37
61
|
});
|
|
62
|
+
for (const warning of await buildTheme({
|
|
63
|
+
projectDir,
|
|
64
|
+
outFile: path.join(publicDir, bundleLayout.theme)
|
|
65
|
+
})) onWarning?.(warning);
|
|
38
66
|
const manifest = {
|
|
39
|
-
version:
|
|
67
|
+
version: 3,
|
|
40
68
|
protobase: protobaseVersion,
|
|
41
69
|
...bundleLayout,
|
|
42
70
|
api: [...bundleLayout.api],
|
|
71
|
+
plugins,
|
|
72
|
+
shell,
|
|
43
73
|
...packages.length > 0 ? { nodeModules: bundleNodeModules } : {}
|
|
44
74
|
};
|
|
45
75
|
await writeFile(path.join(outDir, manifestFile), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { uiPackageDir } from "../project/admin-app.js";
|
|
2
1
|
import { findPackageDirSync, readPackageJson } from "./packages/package-dir.js";
|
|
2
|
+
import { uiPackageDir } from "../project/admin-app.js";
|
|
3
|
+
import { extensionImports } from "../project/extensions.js";
|
|
3
4
|
import { sharedModules } from "../project/shared-modules.js";
|
|
5
|
+
import { pluginFieldTypes } from "./plugin-fields.js";
|
|
4
6
|
import { createRequire } from "node:module";
|
|
5
7
|
import { existsSync } from "node:fs";
|
|
6
8
|
import path from "node:path";
|
|
@@ -16,12 +18,12 @@ const pluginEntryFile = "protobase.ui.tsx";
|
|
|
16
18
|
const pluginManifestFile = "plugin.json";
|
|
17
19
|
const tailwindDir = path.dirname(createRequire(path.join(uiPackageDir, "package.json")).resolve("tailwindcss/package.json"));
|
|
18
20
|
/**
|
|
19
|
-
* What the plugin is built for, from the @protobase/ui installed for it
|
|
20
|
-
* on, unless the plugin's package.json names other versions in `engines.protobase`.
|
|
21
|
-
* the plugin's stylesheet draws with.
|
|
21
|
+
* What the plugin is built for, from the @protobase/ui installed for it (else for the project it extends): its plugin
|
|
22
|
+
* API, and Protobase from its version on, unless the plugin's package.json names other versions in `engines.protobase`.
|
|
23
|
+
* Also that version's theme, which the plugin's stylesheet draws with, and its folder.
|
|
22
24
|
*/
|
|
23
|
-
const pluginTarget = async (pluginDir, own) => {
|
|
24
|
-
const uiDir = findPackageDirSync("@protobase/ui", pluginDir);
|
|
25
|
+
const pluginTarget = async (pluginDir, own, projectDir) => {
|
|
26
|
+
const uiDir = findPackageDirSync("@protobase/ui", pluginDir) ?? (projectDir === void 0 ? void 0 : findPackageDirSync("@protobase/ui", projectDir));
|
|
25
27
|
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
28
|
const ui = await readPackageJson(uiDir);
|
|
27
29
|
const api = ui.protobase?.pluginApi;
|
|
@@ -34,7 +36,8 @@ const pluginTarget = async (pluginDir, own) => {
|
|
|
34
36
|
return {
|
|
35
37
|
api,
|
|
36
38
|
protobase,
|
|
37
|
-
theme: path.join(uiDir, theme)
|
|
39
|
+
theme: path.join(uiDir, theme),
|
|
40
|
+
uiDir
|
|
38
41
|
};
|
|
39
42
|
};
|
|
40
43
|
const pluginImports = () => ({
|
|
@@ -48,12 +51,17 @@ const pluginImports = () => ({
|
|
|
48
51
|
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
52
|
}
|
|
50
53
|
});
|
|
51
|
-
const
|
|
54
|
+
const inside = (dir, parent) => {
|
|
55
|
+
const relative = path.relative(parent, dir);
|
|
56
|
+
return !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
57
|
+
};
|
|
58
|
+
const styleCode = (pluginDir, theme, outDirs) => [
|
|
52
59
|
"@layer theme, base, components, utilities;",
|
|
53
60
|
`@import ${JSON.stringify(path.join(tailwindDir, "theme.css"))} layer(theme) theme(reference);`,
|
|
54
61
|
`@reference ${JSON.stringify(theme)};`,
|
|
55
62
|
`@import ${JSON.stringify(path.join(tailwindDir, "utilities.css"))} layer(utilities) source(none);`,
|
|
56
63
|
`@source ${JSON.stringify(pluginDir)};`,
|
|
64
|
+
...outDirs.filter((dir) => inside(dir, pluginDir)).map((dir) => `@source not ${JSON.stringify(dir)};`),
|
|
57
65
|
""
|
|
58
66
|
].join("\n");
|
|
59
67
|
const pluginStyles = (file, code) => ({
|
|
@@ -68,21 +76,21 @@ const outputOf = (result) => {
|
|
|
68
76
|
};
|
|
69
77
|
/** The output folder must not hold the plugin's own files, which emptying it would delete. */
|
|
70
78
|
const checkOutDir = (pluginDir, outDir) => {
|
|
71
|
-
|
|
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")}`);
|
|
79
|
+
if (inside(pluginDir, outDir)) 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
80
|
};
|
|
74
81
|
/**
|
|
75
82
|
* 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
|
|
83
|
+
* shared modules by name, its stylesheet, and plugin.json naming both and the field types it registers. The app loads
|
|
84
|
+
* it from the plugin.json's URL.
|
|
77
85
|
*/
|
|
78
|
-
const buildPlugin = async ({ pluginDir, outDir, name }) => {
|
|
86
|
+
const buildPlugin = async ({ pluginDir, outDir, name, projectDir, ignore = [] }) => {
|
|
79
87
|
const entry = path.join(pluginDir, pluginEntryFile);
|
|
80
88
|
if (!existsSync(entry)) throw new Error(`There is no ${pluginEntryFile} in ${pluginDir}; a plugin exports its components and action handlers from it`);
|
|
81
89
|
checkOutDir(pluginDir, outDir);
|
|
82
90
|
const own = existsSync(path.join(pluginDir, "package.json")) ? await readPackageJson(pluginDir) : {};
|
|
83
91
|
const pluginName = (name ?? own.name ?? path.basename(pluginDir)).trim();
|
|
84
92
|
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);
|
|
93
|
+
const target = await pluginTarget(pluginDir, own, projectDir);
|
|
86
94
|
const styles = path.join(pluginDir, "protobase-plugin.css");
|
|
87
95
|
const nodeEnv = process.env.NODE_ENV;
|
|
88
96
|
process.env.NODE_ENV = "production";
|
|
@@ -95,7 +103,8 @@ const buildPlugin = async ({ pluginDir, outDir, name }) => {
|
|
|
95
103
|
logLevel: "warn",
|
|
96
104
|
plugins: [
|
|
97
105
|
pluginImports(),
|
|
98
|
-
|
|
106
|
+
...projectDir === void 0 ? [] : [extensionImports(projectDir, [{ dir: pluginDir }])],
|
|
107
|
+
pluginStyles(styles, styleCode(pluginDir, target.theme, [outDir, ...ignore])),
|
|
99
108
|
react(),
|
|
100
109
|
tailwindcss()
|
|
101
110
|
],
|
|
@@ -124,13 +133,20 @@ const buildPlugin = async ({ pluginDir, outDir, name }) => {
|
|
|
124
133
|
const module = output.find((file) => file.type === "chunk" && file.isEntry && file.facadeModuleId === entry);
|
|
125
134
|
const css = output.find((file) => file.type === "asset" && file.fileName.endsWith(".css"));
|
|
126
135
|
if (!module || !css) throw new Error("The plugin build wrote no module or no stylesheet");
|
|
136
|
+
const fields = await pluginFieldTypes({
|
|
137
|
+
pluginDir,
|
|
138
|
+
entry,
|
|
139
|
+
uiDir: target.uiDir,
|
|
140
|
+
projectDir
|
|
141
|
+
});
|
|
127
142
|
const manifest = {
|
|
128
143
|
name: pluginName,
|
|
129
144
|
...own.version && { version: own.version },
|
|
130
145
|
api: target.api,
|
|
131
146
|
protobase: target.protobase,
|
|
132
147
|
entry: module.fileName,
|
|
133
|
-
css: css.fileName
|
|
148
|
+
css: css.fileName,
|
|
149
|
+
...Object.keys(fields).length > 0 && { fields }
|
|
134
150
|
};
|
|
135
151
|
await writeFile(path.join(outDir, pluginManifestFile), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
136
152
|
return manifest;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { extensionImports } from "../project/extensions.js";
|
|
2
|
+
import { sharedModules } from "../project/shared-modules.js";
|
|
3
|
+
import { viteBundle } from "./vite-bundle.js";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
//#region src/build/plugin-fields.ts
|
|
10
|
+
const fieldsCode = (entry) => [
|
|
11
|
+
`import * as ui from ${JSON.stringify(entry)}`,
|
|
12
|
+
`import { mergeUi, uiFromModule } from '@protobase/ui'`,
|
|
13
|
+
"const { fields = {} } = mergeUi(uiFromModule(ui))",
|
|
14
|
+
"export default Object.fromEntries(Object.entries(fields).map(([name, type]) => [name, type.of]))",
|
|
15
|
+
""
|
|
16
|
+
].join("\n");
|
|
17
|
+
const fieldsEntry = (file, code) => ({
|
|
18
|
+
name: "protobase-plugin-fields",
|
|
19
|
+
resolveId: (id) => id === file ? `\0${file}` : void 0,
|
|
20
|
+
load: (id) => id === `\0${file}` ? code : void 0
|
|
21
|
+
});
|
|
22
|
+
const sharedFromUi = (uiDir) => {
|
|
23
|
+
const require = createRequire(path.join(uiDir, "package.json"));
|
|
24
|
+
return {
|
|
25
|
+
name: "protobase-plugin-fields-shared",
|
|
26
|
+
enforce: "pre",
|
|
27
|
+
resolveId: (id) => id === "@protobase/ui" || !sharedModules.includes(id) ? void 0 : require.resolve(id)
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
const isStorageTypes = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
31
|
+
/**
|
|
32
|
+
* The field types a plugin registers (`definePlugin({ fields })`), each with the storage types it shows (`of`), which
|
|
33
|
+
* the server checks the views against at startup. The plugin's module is loaded once in Node, everything bundled into
|
|
34
|
+
* a scratch file, as `protobase build` loads a project's UI to check its names; nothing is rendered.
|
|
35
|
+
*/
|
|
36
|
+
const pluginFieldTypes = async ({ pluginDir, entry, uiDir, projectDir }) => {
|
|
37
|
+
const file = path.join(pluginDir, "protobase-plugin-fields.js");
|
|
38
|
+
const scratch = await mkdtemp(path.join(tmpdir(), "protobase-plugin-fields-"));
|
|
39
|
+
const outFile = path.join(scratch, "fields.mjs");
|
|
40
|
+
try {
|
|
41
|
+
const plugins = [
|
|
42
|
+
fieldsEntry(file, fieldsCode(entry)),
|
|
43
|
+
sharedFromUi(uiDir),
|
|
44
|
+
...projectDir === void 0 ? [] : [extensionImports(projectDir, [{ dir: pluginDir }])]
|
|
45
|
+
];
|
|
46
|
+
await viteBundle({
|
|
47
|
+
root: pluginDir,
|
|
48
|
+
entry: file,
|
|
49
|
+
outFile,
|
|
50
|
+
plugins,
|
|
51
|
+
logLevel: "error"
|
|
52
|
+
});
|
|
53
|
+
const fields = (await import(pathToFileURL(outFile).href)).default;
|
|
54
|
+
const wrong = Object.entries(fields).find(([, of]) => !isStorageTypes(of));
|
|
55
|
+
if (wrong) throw new Error(`The field type "${wrong[0]}" names no storage types it shows; give it of: ['integer'] or the like`);
|
|
56
|
+
return fields;
|
|
57
|
+
} finally {
|
|
58
|
+
await rm(scratch, {
|
|
59
|
+
recursive: true,
|
|
60
|
+
force: true
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
//#endregion
|
|
65
|
+
export { pluginFieldTypes };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { moduleFile } from "../module-file.js";
|
|
2
1
|
import { viteBundle } from "./vite-bundle.js";
|
|
2
|
+
import { moduleFile } from "../module-file.js";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
//#region src/build/serve-runtime.ts
|
|
5
5
|
const cliRoot = fileURLToPath(new URL("../..", import.meta.url));
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { projectUiPlugin } from "../project/project-ui.js";
|
|
2
|
+
import { readPackageJson } from "./packages/package-dir.js";
|
|
3
|
+
import { adminAppDir, uiEntry, uiPackageDir } from "../project/admin-app.js";
|
|
4
|
+
import { sharedModulesPlugin } from "../project/shared-modules.js";
|
|
5
|
+
import { themeLink } from "../project/theme-link.js";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { build } from "vite";
|
|
9
|
+
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
10
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
11
|
+
import react from "@vitejs/plugin-react";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
//#region src/build/shell.ts
|
|
14
|
+
/** What a shell folder holds beside its files: the `BundleShell` a bundle's manifest copies. */
|
|
15
|
+
const shellManifestFile = "protobase.shell.json";
|
|
16
|
+
/** The shell's files, as URL paths: its page and its content-hashed assets. */
|
|
17
|
+
const shellPaths = ["/index.html", "/assets"];
|
|
18
|
+
/** Where @protobase/ui's package ships its prebuilt shell, which `protobase build` copies and `protobase serve` serves. */
|
|
19
|
+
const prebuiltShellDir = path.join(uiPackageDir, "dist", "shell");
|
|
20
|
+
const shellIdentity = async () => {
|
|
21
|
+
const ui = await readPackageJson(uiPackageDir);
|
|
22
|
+
if (!ui.version || ui.protobase?.pluginApi === void 0) throw new Error(`${uiPackageDir} names no version or plugin API`);
|
|
23
|
+
return {
|
|
24
|
+
protobase: ui.version,
|
|
25
|
+
pluginApi: ui.protobase.pluginApi,
|
|
26
|
+
paths: shellPaths
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The admin app as a production build, the same for every project: index.html and content-hashed files under assets/,
|
|
31
|
+
* with protobase.shell.json saying which version it is. Asset URLs are absolute, so index.html works as the answer to
|
|
32
|
+
* any deep link. It calls /api on its own origin and loads the project's UI, like every plugin, from what `/meta`
|
|
33
|
+
* names, and links the project's theme at /protobase.theme.css. The modules plugins share with it have entries of
|
|
34
|
+
* their own, which index.html's import map names.
|
|
35
|
+
*/
|
|
36
|
+
const buildShell = async (outDir) => {
|
|
37
|
+
const nodeEnv = process.env.NODE_ENV;
|
|
38
|
+
process.env.NODE_ENV = "production";
|
|
39
|
+
await build({
|
|
40
|
+
configFile: false,
|
|
41
|
+
root: adminAppDir,
|
|
42
|
+
base: "/",
|
|
43
|
+
mode: "production",
|
|
44
|
+
publicDir: false,
|
|
45
|
+
logLevel: "warn",
|
|
46
|
+
plugins: [
|
|
47
|
+
sharedModulesPlugin(),
|
|
48
|
+
projectUiPlugin(void 0),
|
|
49
|
+
themeLink(),
|
|
50
|
+
react(),
|
|
51
|
+
tailwindcss()
|
|
52
|
+
],
|
|
53
|
+
build: {
|
|
54
|
+
outDir,
|
|
55
|
+
emptyOutDir: true,
|
|
56
|
+
chunkSizeWarningLimit: 4096
|
|
57
|
+
}
|
|
58
|
+
}).finally(() => {
|
|
59
|
+
if (nodeEnv === void 0) delete process.env.NODE_ENV;
|
|
60
|
+
else process.env.NODE_ENV = nodeEnv;
|
|
61
|
+
});
|
|
62
|
+
const shell = await shellIdentity();
|
|
63
|
+
await writeFile(path.join(outDir, shellManifestFile), `${JSON.stringify(shell, null, 2)}\n`);
|
|
64
|
+
return shell;
|
|
65
|
+
};
|
|
66
|
+
/** The shell in `dir`, as its protobase.shell.json describes it. */
|
|
67
|
+
const readShell = async (dir) => {
|
|
68
|
+
const file = path.join(dir, shellManifestFile);
|
|
69
|
+
if (!existsSync(file)) throw new Error(`${dir} holds no shell: it has no ${shellManifestFile}`);
|
|
70
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
71
|
+
};
|
|
72
|
+
/** The shell `protobase serve` has of its own: the installed @protobase/ui's, none when the CLI runs from sources. */
|
|
73
|
+
const installedShellDir = path.extname(uiEntry) === ".js" ? prebuiltShellDir : void 0;
|
|
74
|
+
/**
|
|
75
|
+
* The shell `protobase build` copies into a bundle: the prebuilt one of the installed @protobase/ui, or, when the CLI
|
|
76
|
+
* runs from this repository's sources, one built from them into a scratch folder, so it is never older than the code.
|
|
77
|
+
*/
|
|
78
|
+
const shellForBuild = async () => {
|
|
79
|
+
if (installedShellDir) return {
|
|
80
|
+
dir: installedShellDir,
|
|
81
|
+
shell: await readShell(installedShellDir)
|
|
82
|
+
};
|
|
83
|
+
const dir = await mkdtemp(path.join(tmpdir(), "protobase-shell-"));
|
|
84
|
+
return {
|
|
85
|
+
dir,
|
|
86
|
+
shell: await buildShell(dir),
|
|
87
|
+
scratch: true
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
//#endregion
|
|
91
|
+
export { buildShell, installedShellDir, prebuiltShellDir, readShell, shellForBuild, shellManifestFile, shellPaths };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { uiEntry } from "../project/admin-app.js";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { transform } from "lightningcss";
|
|
6
|
+
//#region src/build/theme.ts
|
|
7
|
+
/** The project's theme, at its root, and where a bundle's `public` folder has it: the app links it at `/protobase.theme.css`. */
|
|
8
|
+
const themeFile = "protobase.theme.css";
|
|
9
|
+
const tokensFile = path.join(path.dirname(uiEntry), "tokens.css");
|
|
10
|
+
/** The names of the tokens the installed @protobase/ui reads, such as `--primary` and `--radius`. */
|
|
11
|
+
const themeTokens = async (file = tokensFile) => new Set([...(await readFile(file, "utf8")).matchAll(/(--[\w-]+)\s*:/g)].map(([, name]) => name));
|
|
12
|
+
const isThemeSelector = (selector) => {
|
|
13
|
+
if (selector.length !== 1) return false;
|
|
14
|
+
const [part] = selector;
|
|
15
|
+
return part.type === "pseudo-class" && part.kind === "root" || part.type === "attribute" && part.name === "data-theme" && part.operation?.value === "dark";
|
|
16
|
+
};
|
|
17
|
+
const where = (file, line) => `${file}:${line + 1}`;
|
|
18
|
+
const checkRule = (rule, file) => {
|
|
19
|
+
if (rule.type === "font-face") return {
|
|
20
|
+
problems: [],
|
|
21
|
+
tokens: []
|
|
22
|
+
};
|
|
23
|
+
if (rule.type !== "style") {
|
|
24
|
+
const line = ("value" in rule ? rule.value.loc?.line : void 0) ?? 0;
|
|
25
|
+
return {
|
|
26
|
+
problems: [`${where(file, line)}: @${rule.type} is not allowed; a theme holds token values under :root and [data-theme="dark"], and @font-face`],
|
|
27
|
+
tokens: []
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const { selectors, declarations, rules, loc } = rule.value;
|
|
31
|
+
const problems = [];
|
|
32
|
+
if (!selectors.every(isThemeSelector)) problems.push(`${where(file, loc.line)}: a theme sets tokens on :root and [data-theme="dark"] only, not on other selectors`);
|
|
33
|
+
if ((rules ?? []).length > 0) problems.push(`${where(file, loc.line)}: a theme holds no nested rules`);
|
|
34
|
+
const all = [...declarations?.declarations ?? [], ...declarations?.importantDeclarations ?? []];
|
|
35
|
+
const properties = all.filter((declaration) => declaration.property !== "custom").map((declaration) => declaration.property);
|
|
36
|
+
if (properties.length > 0) problems.push(`${where(file, loc.line)}: ${properties.join(", ")} ${properties.length === 1 ? "is no token" : "are no tokens"}; a theme sets only tokens such as --primary`);
|
|
37
|
+
return {
|
|
38
|
+
problems,
|
|
39
|
+
tokens: all.flatMap((declaration) => declaration.property === "custom" ? [{
|
|
40
|
+
name: declaration.value.name,
|
|
41
|
+
line: loc.line
|
|
42
|
+
}] : [])
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* The theme in `code` minified, once it holds only token values, under :root (light, and dark unless dark sets its
|
|
47
|
+
* own) and [data-theme="dark"], and @font-face rules: no selector into the app, so it works on every version of it.
|
|
48
|
+
* Throws naming each place that holds something else; a token the app does not read is a warning, a likely typo.
|
|
49
|
+
*/
|
|
50
|
+
const compileTheme = (code, file, known) => {
|
|
51
|
+
const problems = [];
|
|
52
|
+
const warnings = [];
|
|
53
|
+
const css = transform({
|
|
54
|
+
filename: file,
|
|
55
|
+
code: Buffer.from(code),
|
|
56
|
+
minify: true,
|
|
57
|
+
visitor: { StyleSheet(sheet) {
|
|
58
|
+
for (const rule of sheet.rules) {
|
|
59
|
+
const checked = checkRule(rule, file);
|
|
60
|
+
problems.push(...checked.problems);
|
|
61
|
+
for (const token of checked.tokens) if (!known.has(token.name)) warnings.push(`${where(file, token.line)}: ${token.name} is not a token the app reads; check its name`);
|
|
62
|
+
}
|
|
63
|
+
return sheet;
|
|
64
|
+
} }
|
|
65
|
+
}).code.toString();
|
|
66
|
+
if (problems.length > 0) throw new Error(`${file} is not a theme:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
|
|
67
|
+
return {
|
|
68
|
+
css,
|
|
69
|
+
warnings
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Writes the project's protobase.theme.css, checked and minified, to `outFile`, and an empty file when it has none, so
|
|
74
|
+
* the stylesheet the app links is always there. Returns the warnings about it.
|
|
75
|
+
*/
|
|
76
|
+
const buildTheme = async ({ projectDir, outFile }) => {
|
|
77
|
+
const source = path.join(projectDir, themeFile);
|
|
78
|
+
if (!existsSync(source)) {
|
|
79
|
+
await writeFile(outFile, "");
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
const { css, warnings } = compileTheme(await readFile(source, "utf8"), themeFile, await themeTokens());
|
|
83
|
+
await writeFile(outFile, css);
|
|
84
|
+
return warnings;
|
|
85
|
+
};
|
|
86
|
+
//#endregion
|
|
87
|
+
export { buildTheme, compileTheme, themeFile, themeTokens };
|
package/dist/build/ui-check.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
+
import { projectUiFile, projectUiId, projectUiPlugin } from "../project/project-ui.js";
|
|
1
2
|
import { extensionImports } from "../project/extensions.js";
|
|
2
3
|
import { viteBundle } from "./vite-bundle.js";
|
|
3
|
-
import { projectUiFile, projectUiId, projectUiPlugin } from "../project/project-ui.js";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
7
|
-
import { pathToFileURL } from "node:url";
|
|
8
7
|
import { tmpdir } from "node:os";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
9
|
//#region src/build/ui-check.ts
|
|
10
10
|
const checkFile = (projectDir) => path.join(projectDir, "protobase-ui-check.js");
|
|
11
11
|
const checkCode = [
|
package/dist/bundle/manifest.js
CHANGED
|
@@ -8,22 +8,32 @@ const bundleLayout = {
|
|
|
8
8
|
server: "protobase.config.js",
|
|
9
9
|
public: "public",
|
|
10
10
|
spa: "index.html",
|
|
11
|
-
api: ["/api"]
|
|
11
|
+
api: ["/api"],
|
|
12
|
+
theme: "protobase.theme.css"
|
|
12
13
|
};
|
|
13
14
|
const bundleNodeModules = "node_modules";
|
|
14
15
|
const isStringList = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
16
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
const isFieldTypes = (value) => isObject(value) && Object.values(value).every(isStringList);
|
|
18
|
+
const isPlugin = (value) => isObject(value) && typeof value.name === "string" && typeof value.path === "string" && Number.isInteger(value.api) && typeof value.protobase === "string" && (value.fields === void 0 || isFieldTypes(value.fields));
|
|
19
|
+
const isShell = (value) => isObject(value) && typeof value.protobase === "string" && Number.isInteger(value.pluginApi) && isStringList(value.paths);
|
|
15
20
|
const parseManifest = (value, file) => {
|
|
16
21
|
const manifest = value;
|
|
17
|
-
if (manifest?.version !==
|
|
18
|
-
const { server, public: publicDir, api, spa, nodeModules } = manifest;
|
|
19
|
-
if (typeof server !== "string" || typeof publicDir !== "string" || typeof spa !== "string" || !isStringList(api)) throw new Error(`${file} needs server, public and
|
|
22
|
+
if (manifest?.version !== 3) throw new Error(`${file} is not a version 3 bundle manifest; rebuild it with \`protobase build\``);
|
|
23
|
+
const { server, public: publicDir, api, spa, plugins, theme, shell, nodeModules } = manifest;
|
|
24
|
+
if (typeof server !== "string" || typeof publicDir !== "string" || typeof spa !== "string" || typeof theme !== "string" || !isStringList(api)) throw new Error(`${file} needs server, public, spa and theme paths and an api list`);
|
|
25
|
+
if (!Array.isArray(plugins) || !plugins.every(isPlugin)) throw new Error(`${file} needs a plugins list, each with a name, path, api and protobase`);
|
|
26
|
+
if (!isShell(shell)) throw new Error(`${file} needs a shell with its protobase version, pluginApi and paths`);
|
|
20
27
|
if (nodeModules !== void 0 && typeof nodeModules !== "string") throw new Error(`${file} has a nodeModules that is no path`);
|
|
21
28
|
return {
|
|
22
|
-
version:
|
|
29
|
+
version: 3,
|
|
23
30
|
server,
|
|
24
31
|
public: publicDir,
|
|
25
32
|
api,
|
|
26
33
|
spa,
|
|
34
|
+
plugins,
|
|
35
|
+
theme,
|
|
36
|
+
shell,
|
|
27
37
|
...nodeModules === void 0 ? {} : { nodeModules }
|
|
28
38
|
};
|
|
29
39
|
};
|
package/dist/dev/run.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { loadNearestEnvFile } from "./env.js";
|
|
2
|
+
import { projectUiPlugin } from "../project/project-ui.js";
|
|
2
3
|
import { adminAppDir, adminAppMain, uiPackageDir } from "../project/admin-app.js";
|
|
3
4
|
import { extensionImports, extensionStyles } from "../project/extensions.js";
|
|
5
|
+
import { sharedModulesPlugin } from "../project/shared-modules.js";
|
|
4
6
|
import { moduleFile } from "../module-file.js";
|
|
5
7
|
import { layoutCheck } from "../build/layout-check.js";
|
|
6
|
-
import {
|
|
7
|
-
import { sharedModulesPlugin } from "../project/shared-modules.js";
|
|
8
|
+
import { themeLink } from "../project/theme-link.js";
|
|
8
9
|
import { checkDatabase } from "./check-database.js";
|
|
9
10
|
import { stopDevJobs } from "./dev-jobs.js";
|
|
10
11
|
import { existsSync } from "node:fs";
|
|
@@ -73,6 +74,7 @@ const runDev = async (options, out) => {
|
|
|
73
74
|
reloadOnProjectChange([options.projectDir, ...extensions.map((extension) => extension.dir)]),
|
|
74
75
|
stopJobsOnClose,
|
|
75
76
|
sharedModulesPlugin(),
|
|
77
|
+
themeLink(options.projectDir),
|
|
76
78
|
projectUiPlugin(options.projectDir, extensions),
|
|
77
79
|
extensionImports(options.projectDir, extensions),
|
|
78
80
|
extensionStyles(extensions),
|
package/dist/main.js
CHANGED
|
@@ -2,8 +2,9 @@ import { loadNearestEnvFile } from "./dev/env.js";
|
|
|
2
2
|
import { registerAuth } from "./auth/register.js";
|
|
3
3
|
import { manifestFile } from "./bundle/manifest.js";
|
|
4
4
|
import { resolveExtensions } from "./project/extensions.js";
|
|
5
|
-
import { buildDeployBundle } from "./build/deploy-bundle.js";
|
|
6
5
|
import { buildPlugin, pluginManifestFile } from "./build/plugin-build.js";
|
|
6
|
+
import { buildShell } from "./build/shell.js";
|
|
7
|
+
import { buildDeployBundle } from "./build/deploy-bundle.js";
|
|
7
8
|
import { buildServeRuntime } from "./build/serve-runtime.js";
|
|
8
9
|
import { resolveConnectionUrl } from "./db/connect.js";
|
|
9
10
|
import { parseHosts } from "./dev/hosts.js";
|
|
@@ -66,16 +67,19 @@ program.command("dev").description("Serve the admin app and API for the project
|
|
|
66
67
|
process.once("SIGINT", () => void server.close().then(() => process.exit(0)));
|
|
67
68
|
process.once("SIGTERM", () => void server.close().then(() => process.exit(0)));
|
|
68
69
|
});
|
|
69
|
-
program.command("build").description("Build the deploy bundle of the project in the current directory: its config module, admin UI and manifest").option("--out <dir>", "output folder", "dist").option("--bun", "have Bun's bundler write the config module, for a bundle only Bun serves (needs bun on PATH)", false).option("--extend <dir>", "merge the protobase.config.ts and protobase.ui.tsx in this folder before the project's, repeatable", collect, []).action(async (opts) => {
|
|
70
|
+
program.command("build").description("Build the deploy bundle of the project in the current directory: its config module, the admin app with the project's UI as a plugin, and the manifest").option("--out <dir>", "output folder", "dist").option("--bun", "have Bun's bundler write the config module, for a bundle only Bun serves (needs bun on PATH)", false).option("--extend <dir>", "merge the protobase.config.ts and protobase.ui.tsx in this folder before the project's, repeatable", collect, []).action(async (opts) => {
|
|
70
71
|
const extensions = resolveExtensions(opts.extend);
|
|
72
|
+
const onWarning = (message) => process.stderr.write(`warning: ${message}\n`);
|
|
71
73
|
const manifest = await buildDeployBundle({
|
|
72
74
|
projectDir: process.cwd(),
|
|
73
75
|
outDir: path.resolve(opts.out),
|
|
74
76
|
bun: opts.bun,
|
|
75
|
-
extensions
|
|
77
|
+
extensions,
|
|
78
|
+
onWarning
|
|
76
79
|
});
|
|
77
80
|
const packages = manifest.nodeModules ? `, ${manifest.nodeModules}/` : "";
|
|
78
|
-
|
|
81
|
+
const plugins = manifest.plugins.length > 0 ? ` with plugins ${manifest.plugins.map((plugin) => plugin.name).join(", ")}` : "";
|
|
82
|
+
out(`built ${opts.out}: ${manifest.server}${packages}, ${manifest.public}/${plugins} and ${manifestFile} (api ${manifest.api.join(", ")})\n`);
|
|
79
83
|
});
|
|
80
84
|
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
85
|
const pluginDir = path.resolve(dir);
|
|
@@ -88,6 +92,10 @@ program.command("build-plugin").description("Build a plugin the admin app loads
|
|
|
88
92
|
const shown = path.relative(process.cwd(), outDir);
|
|
89
93
|
out(`built ${shown.startsWith("..") ? outDir : shown || "."}: ${pluginManifestFile}, ${manifest.entry} and ${manifest.css} (${manifest.name}, plugin API ${manifest.api}, Protobase ${manifest.protobase})\n`);
|
|
90
94
|
});
|
|
95
|
+
program.command("build-shell", { hidden: true }).description("Build the admin app every bundle shares, as @protobase/ui's package and the protobase image ship it").argument("<out>", "output folder").action(async (outDir) => {
|
|
96
|
+
const shell = await buildShell(path.resolve(outDir));
|
|
97
|
+
out(`built ${outDir}: the shell of Protobase ${shell.protobase} (plugin API ${shell.pluginApi})\n`);
|
|
98
|
+
});
|
|
91
99
|
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) => {
|
|
92
100
|
await buildServeRuntime({
|
|
93
101
|
outFile: path.resolve(opts.out),
|
package/dist/package.js
CHANGED
|
@@ -2,9 +2,10 @@ import { configExports, createAdmin, jobDefinitions } from "@protobase/server";
|
|
|
2
2
|
//#region src/project/project-admin.ts
|
|
3
3
|
/**
|
|
4
4
|
* The admin API of a project over `db`, as `dev`, `serve` and `worker` build it: its config module's resources, views,
|
|
5
|
-
* pages and subscriptions, and the rest of its config
|
|
5
|
+
* pages and subscriptions, and the rest of its config; served from a bundle, the plugins it carries and their field
|
|
6
|
+
* types. `jobs` are the project's own and the delivery of its events.
|
|
6
7
|
*/
|
|
7
|
-
const projectAdmin = (project, config, db) => {
|
|
8
|
+
const projectAdmin = (project, config, db, ui) => {
|
|
8
9
|
const { resources, views, pages, userMenu, bottomBars, subscriptions } = configExports(config);
|
|
9
10
|
const own = jobDefinitions(project.jobs ?? {});
|
|
10
11
|
const admin = createAdmin({
|
|
@@ -22,6 +23,10 @@ const projectAdmin = (project, config, db) => {
|
|
|
22
23
|
...project.webhooks && { webhooks: project.webhooks },
|
|
23
24
|
jobs: own,
|
|
24
25
|
subscriptions,
|
|
26
|
+
...ui && {
|
|
27
|
+
bundlePlugins: ui.plugins,
|
|
28
|
+
fieldTypes: ui.fieldTypes
|
|
29
|
+
},
|
|
25
30
|
options: {
|
|
26
31
|
onUnhandledError: (error) => console.error(error),
|
|
27
32
|
...project.options
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { compileTheme, themeFile, themeTokens } from "../build/theme.js";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
//#region src/project/theme-link.ts
|
|
6
|
+
/**
|
|
7
|
+
* Links the project's theme at `/protobase.theme.css` in the app's page, after its own stylesheets, so it themes the
|
|
8
|
+
* sign-in page too. A bundle always has the file (see `buildTheme`); with `projectDir`, the dev server answers it from
|
|
9
|
+
* the project's protobase.theme.css, checked as the build checks it, and empty without one.
|
|
10
|
+
*/
|
|
11
|
+
const themeLink = (projectDir) => {
|
|
12
|
+
let base = "/";
|
|
13
|
+
return {
|
|
14
|
+
name: "protobase-theme-link",
|
|
15
|
+
configResolved: (config) => {
|
|
16
|
+
base = config.base;
|
|
17
|
+
},
|
|
18
|
+
configureServer: (server) => {
|
|
19
|
+
if (projectDir === void 0) return;
|
|
20
|
+
server.middlewares.use(`/${themeFile}`, (_request, response, next) => {
|
|
21
|
+
const file = path.join(projectDir, themeFile);
|
|
22
|
+
(existsSync(file) ? Promise.all([readFile(file, "utf8"), themeTokens()]).then(([code, tokens]) => compileTheme(code, themeFile, tokens).css) : Promise.resolve("")).then((body) => {
|
|
23
|
+
response.setHeader("content-type", "text/css");
|
|
24
|
+
response.setHeader("cache-control", "no-cache");
|
|
25
|
+
response.end(body);
|
|
26
|
+
}, next);
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
transformIndexHtml: {
|
|
30
|
+
order: "post",
|
|
31
|
+
handler: () => [{
|
|
32
|
+
tag: "link",
|
|
33
|
+
attrs: {
|
|
34
|
+
rel: "stylesheet",
|
|
35
|
+
href: `${base}${themeFile}`
|
|
36
|
+
},
|
|
37
|
+
injectTo: "head"
|
|
38
|
+
}]
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { themeLink };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
//#region src/serve/bundle-ui.ts
|
|
2
|
+
const bundleUi = (manifest) => ({
|
|
3
|
+
plugins: manifest.plugins.map((plugin) => `/${plugin.path}`),
|
|
4
|
+
fieldTypes: Object.fromEntries(manifest.plugins.flatMap((plugin) => Object.entries(plugin.fields ?? {})))
|
|
5
|
+
});
|
|
6
|
+
//#endregion
|
|
7
|
+
export { bundleUi };
|
package/dist/serve/lifecycle.js
CHANGED
|
@@ -14,7 +14,7 @@ const listen = (server, port) => new Promise((resolve, reject) => {
|
|
|
14
14
|
});
|
|
15
15
|
});
|
|
16
16
|
const closeServer = (server) => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
17
|
-
const startServe = async ({ project, env, createDb = (url) => createPgDb(pg, url), site }) => {
|
|
17
|
+
const startServe = async ({ project, env, createDb = (url) => createPgDb(pg, url), site, ui }) => {
|
|
18
18
|
if (!project.db && !env.databaseUrl) throw new Error("No database: set DATABASE_URL or export `db` from protobase.config.ts");
|
|
19
19
|
if (project.auth) await checkAuthSchema(project.auth);
|
|
20
20
|
const ownDb = project.db ? void 0 : createDb(env.databaseUrl);
|
|
@@ -23,7 +23,7 @@ const startServe = async ({ project, env, createDb = (url) => createPgDb(pg, url
|
|
|
23
23
|
const startJobsAndListen = async () => {
|
|
24
24
|
await checkChanges(db, resources.map((resource) => resource.toModel()), project.events);
|
|
25
25
|
if (project.webhooks) await checkWebhooksSchema(db, project.webhooks);
|
|
26
|
-
const { admin, jobs: definitions } = projectAdmin(project, project.config, db);
|
|
26
|
+
const { admin, jobs: definitions } = projectAdmin(project, project.config, db, ui);
|
|
27
27
|
if (definitions.length > 0) await checkJobsSchema(db);
|
|
28
28
|
const jobs = definitions.length > 0 ? await startJobs({
|
|
29
29
|
db,
|
|
@@ -1,17 +1,22 @@
|
|
|
1
|
+
import { readManifest } from "../bundle/manifest.js";
|
|
2
|
+
import { bundleUi } from "./bundle-ui.js";
|
|
1
3
|
import { checkBundleVersion } from "./bundle-version.js";
|
|
2
4
|
import { readServeEnv } from "./env.js";
|
|
3
5
|
import { startServe } from "./lifecycle.js";
|
|
4
6
|
import { loadBundle } from "./load-bundle.js";
|
|
5
7
|
import { closeOnSignals } from "./signals.js";
|
|
8
|
+
import path from "node:path";
|
|
6
9
|
//#region src/serve/serve-bundle.ts
|
|
7
10
|
const serveBundle = async (file, env, out, site) => {
|
|
8
11
|
const serveEnv = readServeEnv(env);
|
|
9
12
|
await checkBundleVersion(file);
|
|
13
|
+
const manifest = await readManifest(path.dirname(file));
|
|
10
14
|
const project = await loadBundle(file);
|
|
11
15
|
const server = await startServe({
|
|
12
16
|
project,
|
|
13
17
|
env: serveEnv,
|
|
14
|
-
site
|
|
18
|
+
site,
|
|
19
|
+
ui: bundleUi(manifest)
|
|
15
20
|
});
|
|
16
21
|
closeOnSignals(server.close);
|
|
17
22
|
out(`protobase serve listening on port ${server.port}\n`);
|
package/dist/serve/serve-dir.js
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { readManifest } from "../bundle/manifest.js";
|
|
2
|
+
import { installedShellDir } from "../build/shell.js";
|
|
2
3
|
import { serveBundle } from "./serve-bundle.js";
|
|
4
|
+
import { servedShell, shellVariable } from "./served-shell.js";
|
|
3
5
|
import path from "node:path";
|
|
4
6
|
//#region src/serve/serve-dir.ts
|
|
5
7
|
const serveBundleDir = async (dir, env, out) => {
|
|
6
8
|
const manifest = await readManifest(dir);
|
|
9
|
+
const own = env["PROTOBASE_SHELL"] ? path.resolve(env[shellVariable]) : installedShellDir;
|
|
10
|
+
const { shell, note } = await servedShell(manifest, own);
|
|
7
11
|
const site = {
|
|
8
12
|
publicDir: path.resolve(dir, manifest.public),
|
|
9
13
|
spa: manifest.spa,
|
|
10
|
-
api: manifest.api
|
|
14
|
+
api: manifest.api,
|
|
15
|
+
...shell && { shell }
|
|
11
16
|
};
|
|
12
|
-
|
|
17
|
+
const server = await serveBundle(path.join(dir, manifest.server), env, out, site);
|
|
18
|
+
out(` app ${note}\n`);
|
|
19
|
+
return server;
|
|
13
20
|
};
|
|
14
21
|
//#endregion
|
|
15
22
|
export { serveBundleDir };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readShell } from "../build/shell.js";
|
|
2
|
+
import { pluginProblem } from "@protobase/schema";
|
|
3
|
+
//#region src/serve/served-shell.ts
|
|
4
|
+
/** The environment variable naming a shell folder for `protobase serve`, instead of the installed @protobase/ui's. */
|
|
5
|
+
const shellVariable = "PROTOBASE_SHELL";
|
|
6
|
+
/**
|
|
7
|
+
* The shell a server of its own shell `ownDir` serves a bundle with: its own, when every plugin of the bundle loads in
|
|
8
|
+
* it, so the app follows the server's version without a rebuild; otherwise none, and the bundle's copy serves, with
|
|
9
|
+
* the reason in `note`.
|
|
10
|
+
*/
|
|
11
|
+
const servedShell = async (manifest, ownDir) => {
|
|
12
|
+
if (ownDir === void 0) return { note: "the bundle's copy of the app" };
|
|
13
|
+
const own = await readShell(ownDir);
|
|
14
|
+
for (const plugin of manifest.plugins) {
|
|
15
|
+
const problem = pluginProblem(plugin, {
|
|
16
|
+
api: own.pluginApi,
|
|
17
|
+
version: own.protobase
|
|
18
|
+
});
|
|
19
|
+
if (problem) return { note: `the bundle's copy of the app: its plugin ${plugin.name} would be off in the app of Protobase ${own.protobase}, since ${problem}` };
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
shell: {
|
|
23
|
+
dir: ownDir,
|
|
24
|
+
paths: own.paths
|
|
25
|
+
},
|
|
26
|
+
note: `the app of Protobase ${own.protobase} from ${ownDir}`
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
export { servedShell, shellVariable };
|
|
@@ -26,20 +26,24 @@ const fileFor = async (publicDir, pathname) => {
|
|
|
26
26
|
const index = path.join(file, "index.html");
|
|
27
27
|
return (await statOrUndefined(index))?.isFile() ? index : void 0;
|
|
28
28
|
};
|
|
29
|
-
const cacheControl = (
|
|
30
|
-
const fileResponse = async (
|
|
29
|
+
const cacheControl = (root, file) => path.relative(root, file).split(path.sep).slice(0, -1).includes("assets") ? "public, max-age=31536000, immutable" : "no-cache";
|
|
30
|
+
const fileResponse = async (root, file) => new Response(await readFile(file), { headers: {
|
|
31
31
|
"content-type": getMimeType(file) ?? "application/octet-stream",
|
|
32
|
-
"cache-control": cacheControl(
|
|
32
|
+
"cache-control": cacheControl(root, file)
|
|
33
33
|
} });
|
|
34
34
|
const isNavigation = (request) => (request.headers.get("accept") ?? "").includes("text/html");
|
|
35
|
-
const
|
|
35
|
+
const isUnder = (paths, pathname) => paths.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
|
36
|
+
const serveStaticSite = async ({ publicDir, spa, shell }, request) => {
|
|
36
37
|
if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method Not Allowed", {
|
|
37
38
|
status: 405,
|
|
38
39
|
headers: { allow: "GET, HEAD" }
|
|
39
40
|
});
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
const pathname = new URL(request.url).pathname;
|
|
42
|
+
const appDir = shell?.dir ?? publicDir;
|
|
43
|
+
const root = shell && (pathname === "/" || isUnder(shell.paths, pathname)) ? shell.dir : publicDir;
|
|
44
|
+
const file = await fileFor(root, pathname);
|
|
45
|
+
if (file) return fileResponse(root, file);
|
|
46
|
+
if (isNavigation(request)) return fileResponse(appDir, path.join(appDir, spa));
|
|
43
47
|
return new Response("Not Found", { status: 404 });
|
|
44
48
|
};
|
|
45
49
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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.
|
|
38
|
-
"@protobase/query": "0.
|
|
39
|
-
"@protobase/schema": "0.
|
|
40
|
-
"@protobase/server": "0.
|
|
41
|
-
"@protobase/ui": "0.
|
|
37
|
+
"@protobase/layout": "0.12.1",
|
|
38
|
+
"@protobase/query": "0.12.1",
|
|
39
|
+
"@protobase/schema": "0.12.1",
|
|
40
|
+
"@protobase/server": "0.12.1",
|
|
41
|
+
"@protobase/ui": "0.12.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",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"hono": "^4.13.13",
|
|
50
50
|
"jose": "^6.2.12",
|
|
51
51
|
"kysely": "^0.29.6",
|
|
52
|
+
"lightningcss": "^1.33.0",
|
|
52
53
|
"pg": "^8.23.1",
|
|
53
54
|
"postgres": "^3.4.9",
|
|
54
55
|
"ts-morph": "^28.0.0",
|
package/dist/build/ui-bundle.js
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { adminAppDir } from "../project/admin-app.js";
|
|
2
|
-
import { extensionImports, extensionStyles } from "../project/extensions.js";
|
|
3
|
-
import { projectUiPlugin } from "../project/project-ui.js";
|
|
4
|
-
import { sharedModulesPlugin } from "../project/shared-modules.js";
|
|
5
|
-
import { build } from "vite";
|
|
6
|
-
import tailwindcss from "@tailwindcss/vite";
|
|
7
|
-
import react from "@vitejs/plugin-react";
|
|
8
|
-
//#region src/build/ui-bundle.ts
|
|
9
|
-
const buildUiBundle = async ({ outDir, projectDir, extensions = [] }) => {
|
|
10
|
-
const nodeEnv = process.env.NODE_ENV;
|
|
11
|
-
process.env.NODE_ENV = "production";
|
|
12
|
-
await build({
|
|
13
|
-
configFile: false,
|
|
14
|
-
root: adminAppDir,
|
|
15
|
-
base: "/",
|
|
16
|
-
mode: "production",
|
|
17
|
-
publicDir: false,
|
|
18
|
-
logLevel: "warn",
|
|
19
|
-
plugins: [
|
|
20
|
-
sharedModulesPlugin(),
|
|
21
|
-
projectUiPlugin(projectDir, extensions),
|
|
22
|
-
...projectDir === void 0 ? [] : [extensionImports(projectDir, extensions)],
|
|
23
|
-
extensionStyles(extensions),
|
|
24
|
-
react(),
|
|
25
|
-
tailwindcss()
|
|
26
|
-
],
|
|
27
|
-
build: {
|
|
28
|
-
outDir,
|
|
29
|
-
emptyOutDir: true,
|
|
30
|
-
chunkSizeWarningLimit: 4096
|
|
31
|
-
}
|
|
32
|
-
}).finally(() => {
|
|
33
|
-
if (nodeEnv === void 0) delete process.env.NODE_ENV;
|
|
34
|
-
else process.env.NODE_ENV = nodeEnv;
|
|
35
|
-
});
|
|
36
|
-
};
|
|
37
|
-
//#endregion
|
|
38
|
-
export { buildUiBundle };
|