@olenbetong/appframe-vite 6.5.0 → 6.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,6 +40,17 @@ Make sure your Appframe credentials are available as environment variables:
40
40
 
41
41
  On first run, the plugin logs in, configures Vite’s proxy for Appframe routes, and serves the article HTML with your local entry injected.
42
42
 
43
+ ### MUI X license key
44
+
45
+ The plugin injects the MUI X Pro license key into the bundle as the global `__MUI_X_LICENSE_KEY__`, for both `serve` and `build`. `@olenbetong/appframe-ds/grid` reads it and registers the license itself, so an app using the Designsystemet `AfGrid` does not need its own `LicenseInfo.setLicenseKey()` call.
46
+
47
+ The key is resolved from, in order:
48
+
49
+ 1. the `MUI_X_LICENSE_KEY` environment variable (CI, a local `.env`, or the shell),
50
+ 2. the `MUI_X_LICENSE_KEY` constant declared in the app's `src/shared/licenses.ts`.
51
+
52
+ The fallback keeps existing apps working with no changes; setting the environment variable is the forward path for getting the key out of the repository.
53
+
43
54
  ## Project configuration (package.json)
44
55
 
45
56
  This package reads `package.json.appframe` to know what to proxy and how to build.
@@ -48,37 +59,37 @@ This package reads `package.json.appframe` to know what to proxy and how to buil
48
59
  {
49
60
  "appframe": {
50
61
  "article": {
51
- "id": "my-article", // Required
52
- "altNames": ["my-article-dev"] // Optional: alternate root paths
62
+ "id": "my-article", // Required
63
+ "altNames": ["my-article-dev"], // Optional: alternate root paths
53
64
  },
54
65
  "deploy": { "hostname": "dev.obet.no" }, // Default server if proxy not set
55
66
  "proxy": {
56
- "hostname": "dev.obet.no", // Optional override for dev proxy
57
- "routes": ["^/custom/.*"] // Optional: extra proxied routes
67
+ "hostname": "dev.obet.no", // Optional override for dev proxy
68
+ "routes": ["^/custom/.*"], // Optional: extra proxied routes
58
69
  },
59
70
  "build": {
60
- "externals": true // Map React and ReactDOM to globals
61
- }
62
- }
71
+ "externals": true, // Map React and ReactDOM to globals
72
+ },
73
+ },
63
74
  }
64
75
  ```
65
76
 
66
77
  ## What it does (at a glance)
67
78
 
68
79
  - Dev server
69
- - Proxies Appframe routes (e.g. `/api`, `/file`, `/lib`, etc.) to the configured hostname.
70
- - Performs an initial login and refreshes the session every 5 minutes to prevent 403 errors.
71
- - Serves the real article HTML from Appframe, but removes production assets and injects your local entry (`/src/index.*`).
72
- - Caches localized strings under `node_modules/.appframe/localizeCache.json` and injects them into the page to avoid many small HTTP 1.1 requests.
80
+ - Proxies Appframe routes (e.g. `/api`, `/file`, `/lib`, etc.) to the configured hostname.
81
+ - Performs an initial login and refreshes the session every 5 minutes to prevent 403 errors.
82
+ - Serves the real article HTML from Appframe, but removes production assets and injects your local entry (`/src/index.*`).
83
+ - Caches localized strings under `node_modules/.appframe/localizeCache.json` and injects them into the page to avoid many small HTTP 1.1 requests.
73
84
  - Build
74
- - Enables `manifest` and `sourcemap`.
75
- - Writes filenames to Appframe paths, using the article ID, e.g.:
76
- - `file/article/script/<ARTICLE_ID>/main.[hash].min.js`
77
- - `file/article/style/<ARTICLE_ID>/style.[hash].min.css`
78
- - Optionally treats `react` and `react-dom` as externals and maps them to `React` and `ReactDOM` globals.
79
- - Adds a Rollup visualizer report at `dist/stats.html`.
85
+ - Enables `manifest` and `sourcemap`.
86
+ - Writes filenames to Appframe paths, using the article ID, e.g.:
87
+ - `file/article/script/<ARTICLE_ID>/main.[hash].min.js`
88
+ - `file/article/style/<ARTICLE_ID>/style.[hash].min.css`
89
+ - Optionally treats `react` and `react-dom` as externals and maps them to `React` and `ReactDOM` globals.
90
+ - Adds a Rollup visualizer report at `dist/stats.html`.
80
91
  - Resolve
81
- - Adds the alias `~/` to `/src/` so imports like `~/components/Button` resolve to `src/components/Button`.
92
+ - Adds the alias `~/` to `/src/` so imports like `~/components/Button` resolve to `src/components/Button`.
82
93
 
83
94
  ## CLI — `appframe-vite`
84
95
 
@@ -120,6 +131,34 @@ appframe-vite resources edit dsAccountGroups
120
131
  appframe-vite resources edit
121
132
  ```
122
133
 
134
+ ### `resources migrate [id]`
135
+
136
+ Migrates data objects and procedures that are configured on the article in the appdesigner over to `resources.yaml`. The article's data sources and their fields are read from the CMS, converted to `resources.yaml` entries, and the output files are generated.
137
+
138
+ Each migrated view/procedure is also registered as a Data API resource if needed (the same thing `af resources add` does), because `af.data.generateApiDataObject` and `af.ProcedureAPI` go through `/api/data` instead of the article's data sources.
139
+
140
+ ```sh
141
+ # migrate everything on the article
142
+ appframe-vite resources migrate
143
+ # migrate a single data object or procedure
144
+ appframe-vite resources migrate dsVouchers
145
+ # preview without changing anything
146
+ appframe-vite resources migrate --dry-run
147
+ ```
148
+
149
+ Options:
150
+
151
+ | Option | Description |
152
+ | ------------------------ | -------------------------------------------------------------- |
153
+ | `-c, --config <path>` | Path to the resources config file (default: `resources.yaml`) |
154
+ | `-o, --output-dir <dir>` | Directory for the generated files (default: `src/data`) |
155
+ | `--dry-run` | Show what would be migrated without writing anything |
156
+ | `--no-generate` | Only update `resources.yaml`, do not generate the output files |
157
+
158
+ Existing `resources.yaml` entries with the same id are updated in place. Field aliases have no `resources.yaml` equivalent and are reported as warnings.
159
+
160
+ After migrating, import the generated files from your app, remove the data objects and procedures from the article in the appdesigner, and run `pnpm generate-types`.
161
+
123
162
  ## `resources.yaml` config file
124
163
 
125
164
  Place `resources.yaml` at the project root to declare all data objects and procedures for an app. The Vite dev server watches this file and auto-regenerates all output files when it changes.
@@ -169,27 +208,27 @@ procedures:
169
208
 
170
209
  **Supported fields per entry:**
171
210
 
172
- | Field | Type | Description |
173
- |---|---|---|
174
- | `id` | `string` | Variable name used in generated code (e.g. `dsAccountGroups`) |
175
- | `resource` | `string` | Database object ID (e.g. `atbv_Accounting_SubsidiaryLedgerGroups`) |
176
- | `output` | `string` | Output file path relative to project root |
177
- | `global` | `boolean` | Use `af.data.generateApiDataObject` / `new af.ProcedureAPI` globals |
178
- | `types` | `boolean` | Emit TypeScript type definitions |
179
- | `permissions` | `string` | Permissions: I = insert, U = update, D = delete (e.g. `IUD`) |
180
- | `maxRecords` | `number` | Max records to fetch (default `50`; `-1` for all) |
181
- | `sortOrder` | `string \| string[]` | Sort order, e.g. `Created:Desc` or `[Created:Desc, Name]` |
182
- | `master` | `string` | Master data object name (or `name:importPath`) |
183
- | `linkFields` | `string \| string[]` | Fields linking child to master |
184
- | `expose` | `boolean \| string` | Expose on `af.article.dataObjects` / `af.article.procedures` |
185
- | `dynamic` | `boolean` | Enable dynamic loading |
186
- | `unique` | `string` | Unique table name for update/delete |
187
- | `overrides` | `string \| string[]` | Type overrides, e.g. `MyField:string[]` |
188
- | `distinct` | `boolean` | Fetch distinct rows |
189
- | `aggregates` | `string \| string[]` | Aggregate bindings, e.g. `Qty:SUM` |
190
- | `groupBy` | `string \| string[]` | Group-by fields |
191
- | `where` | `string` | Initial where clause |
192
- | `fields` | `string \| string[]` | Fields to include (all if omitted) |
211
+ | Field | Type | Description |
212
+ | ------------- | -------------------- | ------------------------------------------------------------------- |
213
+ | `id` | `string` | Variable name used in generated code (e.g. `dsAccountGroups`) |
214
+ | `resource` | `string` | Database object ID (e.g. `atbv_Accounting_SubsidiaryLedgerGroups`) |
215
+ | `output` | `string` | Output file path relative to project root |
216
+ | `global` | `boolean` | Use `af.data.generateApiDataObject` / `new af.ProcedureAPI` globals |
217
+ | `types` | `boolean` | Emit TypeScript type definitions |
218
+ | `permissions` | `string` | Permissions: I = insert, U = update, D = delete (e.g. `IUD`) |
219
+ | `maxRecords` | `number` | Max records to fetch (default `50`; `-1` for all) |
220
+ | `sortOrder` | `string \| string[]` | Sort order, e.g. `Created:Desc` or `[Created:Desc, Name]` |
221
+ | `master` | `string` | Master data object name (or `name:importPath`) |
222
+ | `linkFields` | `string \| string[]` | Fields linking child to master |
223
+ | `expose` | `boolean \| string` | Expose on `af.article.dataObjects` / `af.article.procedures` |
224
+ | `dynamic` | `boolean` | Enable dynamic loading |
225
+ | `unique` | `string` | Unique table name for update/delete |
226
+ | `overrides` | `string \| string[]` | Type overrides, e.g. `MyField:string[]` |
227
+ | `distinct` | `boolean` | Fetch distinct rows |
228
+ | `aggregates` | `string \| string[]` | Aggregate bindings, e.g. `Qty:SUM` |
229
+ | `groupBy` | `string \| string[]` | Group-by fields |
230
+ | `where` | `string` | Initial where clause |
231
+ | `fields` | `string \| string[]` | Fields to include (all if omitted) |
193
232
 
194
233
  A top-level `server` key can override the hostname (defaults to `appframe.proxy.hostname` from `package.json`).
195
234
 
@@ -201,12 +240,11 @@ Shared code generation utilities for Node.js consumers:
201
240
 
202
241
  ```ts
203
242
  import {
204
- fetchAndGenerate,
205
- buildYamlConfig,
206
- parseYamlConfig,
207
- formatWithBiome,
208
- getCustomImportPath,
209
- type CLIOptions,
243
+ fetchAndGenerate,
244
+ buildYamlConfig,
245
+ parseYamlConfig,
246
+ formatWithBiome,
247
+ getCustomImportPath,
248
+ type CLIOptions,
210
249
  } from "@olenbetong/appframe-vite/resources";
211
250
  ```
212
-
@@ -0,0 +1,14 @@
1
+ import type { Plugin } from "vite";
2
+ /**
3
+ * Redirects imports of `@olenbetong/appframe-data` to the `af.data` global the article
4
+ * already loads.
5
+ *
6
+ * Apps themselves mostly use the globals directly, but the `@olenbetong/*` packages they
7
+ * depend on import the package by name. Without this, every app ships a second copy of
8
+ * appframe-data whose module-scoped `defaultClient` is never assigned — the assignment
9
+ * lives in a bare side effect at the end of `browser/Client.js`, which tree shaking drops
10
+ * because the package declares `sideEffects: false` and nothing imports that module's only
11
+ * export. Anything reaching `getDefaultClient()` through that copy then fails with
12
+ * "No default client has been set". The dev server hid this by not tree shaking.
13
+ */
14
+ export declare function appframeDataGlobal(): Plugin;
@@ -0,0 +1,114 @@
1
+ import path from "node:path";
2
+ const PACKAGE_ID = "@olenbetong/appframe-data";
3
+ const VIRTUAL_ID = "\0appframe-data-global";
4
+ /**
5
+ * Names the framework puts on the `af.data` global. These are the exports shared by
6
+ * `bundle-entries/browser.esm.ts` and `bundle-entries/browser-compat.esm.ts` — which of
7
+ * the two a page loads depends on the user's UserMode, so only names present in *both*
8
+ * are safe to read off the global.
9
+ */
10
+ const GLOBAL_EXPORTS = [
11
+ "Client",
12
+ "DataHandler",
13
+ "DataObject",
14
+ "DataProviderHandler",
15
+ "DataProviderHandlerAPI",
16
+ "FileUploader",
17
+ "MemoryStorage",
18
+ "Paging",
19
+ "Procedure",
20
+ "ProcedureAPI",
21
+ "SortOrder",
22
+ "generateApiDataHandler",
23
+ "generateApiDataObject",
24
+ "getDefaultClient",
25
+ "setDefaultClient",
26
+ "uid",
27
+ ];
28
+ /**
29
+ * Helpers the `af.data` bundles do not expose, mapped to the module they live in. These
30
+ * are pure functions whose only dependencies on the rest of the package are types, so
31
+ * bundling them creates no second client, data object or event bus — the state that must
32
+ * stay single-instance still comes from the global. Move an entry up to GLOBAL_EXPORTS
33
+ * once the browser bundle entries export it and the new af.data has been deployed.
34
+ */
35
+ const LOCAL_EXPORTS = {
36
+ "formDataToObject.js": ["formDataToDataObjectRecord", "formDataToObject", "formDataToProcedureParameters"],
37
+ "exportDataObject.js": ["exportOptionsFromDataObject", "getDataObjectReportRequest", "openDataObjectReport"],
38
+ "DataHandler.js": ["isRequestError"],
39
+ };
40
+ const MISSING_GLOBAL_MESSAGE = "af.data is not available. Appframe articles load it before the app bundle, so this " +
41
+ "usually means the page was opened outside an article context.";
42
+ function renderModule(esDir) {
43
+ let lines = [
44
+ `// Generated by @olenbetong/appframe-vite (appframeDataGlobal.ts).`,
45
+ `// ${PACKAGE_ID} is provided by the article as the af.data global and must not be`,
46
+ `// bundled: a bundled copy has its own default client, which nothing ever sets.`,
47
+ ``,
48
+ ];
49
+ for (let [file, names] of Object.entries(LOCAL_EXPORTS)) {
50
+ let id = path.join(esDir, file).replace(/\\/g, "/");
51
+ lines.push(`export { ${names.join(", ")} } from ${JSON.stringify(id)};`);
52
+ }
53
+ lines.push(``, `let data = globalThis.af && globalThis.af.data;`, `if (!data) {`, `\tthrow new Error(${JSON.stringify(MISSING_GLOBAL_MESSAGE)});`, `}`, ``);
54
+ for (let name of GLOBAL_EXPORTS) {
55
+ lines.push(`export const ${name} = data.${name};`);
56
+ }
57
+ return `${lines.join("\n")}\n`;
58
+ }
59
+ /**
60
+ * Redirects imports of `@olenbetong/appframe-data` to the `af.data` global the article
61
+ * already loads.
62
+ *
63
+ * Apps themselves mostly use the globals directly, but the `@olenbetong/*` packages they
64
+ * depend on import the package by name. Without this, every app ships a second copy of
65
+ * appframe-data whose module-scoped `defaultClient` is never assigned — the assignment
66
+ * lives in a bare side effect at the end of `browser/Client.js`, which tree shaking drops
67
+ * because the package declares `sideEffects: false` and nothing imports that module's only
68
+ * export. Anything reaching `getDefaultClient()` through that copy then fails with
69
+ * "No default client has been set". The dev server hid this by not tree shaking.
70
+ */
71
+ export function appframeDataGlobal() {
72
+ let root = process.cwd();
73
+ let esDir;
74
+ /**
75
+ * Locates the package's `es` directory, where the helpers below are read from.
76
+ * Resolved on demand rather than cached from `resolveId`, because the dev server can
77
+ * load a module straight from a warm module graph without resolving its id first.
78
+ */
79
+ async function resolveEsDir(importer) {
80
+ if (esDir)
81
+ return esDir;
82
+ // skipSelf avoids recursing into this plugin's own resolveId. The result is the
83
+ // package's browser entry (es/index.js), so its directory is what we want.
84
+ let resolved = await this.resolve(PACKAGE_ID, importer ?? path.join(root, "index.html"), { skipSelf: true });
85
+ if (resolved)
86
+ esDir = path.dirname(resolved.id);
87
+ return esDir;
88
+ }
89
+ return {
90
+ name: "appframe:data-global",
91
+ // Must beat Vite's own resolver, and keep the dep optimizer from pre-bundling a
92
+ // copy in dev before this hook ever runs.
93
+ enforce: "pre",
94
+ config() {
95
+ return { optimizeDeps: { exclude: [PACKAGE_ID] } };
96
+ },
97
+ configResolved(config) {
98
+ root = config.root;
99
+ },
100
+ async resolveId(source, importer) {
101
+ if (source !== PACKAGE_ID)
102
+ return null;
103
+ return (await resolveEsDir.call(this, importer)) ? VIRTUAL_ID : null;
104
+ },
105
+ async load(id) {
106
+ if (id !== VIRTUAL_ID)
107
+ return null;
108
+ let dir = await resolveEsDir.call(this, undefined);
109
+ if (!dir)
110
+ throw new Error(`Could not resolve ${PACKAGE_ID} from ${root}.`);
111
+ return renderModule(dir);
112
+ },
113
+ };
114
+ }
package/lib/build.js CHANGED
@@ -25,6 +25,19 @@ export async function addAppframeBuildConfig(config) {
25
25
  // Cast visualizer plugin to any to avoid Rollup type version clashes
26
26
  plugins: [visualizer({ filename: "./dist/stats.html", gzipSize: true })],
27
27
  external: appframe.build.externals !== false ? ["react", "react-dom", "react-dom/client"] : [],
28
+ treeshake: {
29
+ /*
30
+ * `@digdir/designsystemet-react` does not declare `sideEffects: false`, so the
31
+ * bundler has to assume every module reached through its barrel is needed and
32
+ * ships all ~140 components (Combobox, Table, Suggestion, …) even when an app
33
+ * renders a handful. None of its modules have import-time side effects, so opt
34
+ * them in to tree shaking explicitly.
35
+ *
36
+ * Deliberately scoped to `designsystemet-react`: `designsystemet-web` registers
37
+ * custom elements on import and must keep its side effects.
38
+ */
39
+ moduleSideEffects: (id) => !/[\\/]node_modules[\\/].*designsystemet-react[\\/]/.test(id.replace(/\?.*$/, "")),
40
+ },
28
41
  output: {
29
42
  globals: appframe.build.externals !== false
30
43
  ? {
@@ -0,0 +1,7 @@
1
+ export type MigrateOptions = {
2
+ config?: string;
3
+ outputDir?: string;
4
+ dryRun?: boolean;
5
+ generate?: boolean;
6
+ };
7
+ export declare function migrateArticleResources(id?: string, options?: MigrateOptions): Promise<void>;
@@ -0,0 +1,341 @@
1
+ import { resolve } from "node:path";
2
+ import { Client, generateApiDataHandler } from "@olenbetong/appframe-data";
3
+ import { config } from "dotenv";
4
+ import { getLoginInfo } from "./devServer.js";
5
+ import { fetchAndGenerate, writeGeneratedFile } from "./resourceGenerate.js";
6
+ import { entryToCLIOptions, readResourcesConfig, writeResourcesConfig, } from "./resourcesConfig.js";
7
+ config({ quiet: true });
8
+ /** Views holding the CMS article configuration. They are registered on demand. */
9
+ const CMS_DATASOURCES_VIEW = "stbv_WebSiteCMS_Datasources";
10
+ const CMS_DATASOURCE_FIELDS_VIEW = "stbv_WebSiteCMS_DatasourcesFields";
11
+ const CMS_PROCEDURES_VIEW = "stbv_WebSiteCMS_Procedures";
12
+ // ---------------------------------------------------------------------------
13
+ // Data handlers
14
+ // ---------------------------------------------------------------------------
15
+ function field(name, type, nullable = true) {
16
+ return { name, type, nullable, computed: false, identity: false, hasDefault: false };
17
+ }
18
+ function createApiResourcesHandler(client) {
19
+ return generateApiDataHandler({
20
+ client,
21
+ resource: "API_Resources",
22
+ fields: [field("PrimKey", "string", false), field("DBObjectID", "string", false), field("Name", "string")],
23
+ });
24
+ }
25
+ function createDatasourcesHandler(client) {
26
+ return generateApiDataHandler({
27
+ client,
28
+ resource: CMS_DATASOURCES_VIEW,
29
+ fields: [
30
+ field("ID", "string", false),
31
+ field("ViewName", "string"),
32
+ field("UniqueTableName", "string"),
33
+ field("WhereClause", "string"),
34
+ field("AllowInsert", "boolean", false),
35
+ field("AllowUpdate", "boolean", false),
36
+ field("AllowDelete", "boolean", false),
37
+ field("MaxRecords", "number", false),
38
+ field("MasterID", "string"),
39
+ field("MasterLinkFields", "string"),
40
+ field("ChildLinkFields", "string"),
41
+ field("DynamicLoading", "boolean", false),
42
+ field("DistinctRows", "boolean", false),
43
+ ],
44
+ });
45
+ }
46
+ function createDatasourceFieldsHandler(client) {
47
+ return generateApiDataHandler({
48
+ client,
49
+ resource: CMS_DATASOURCE_FIELDS_VIEW,
50
+ fields: [
51
+ field("Datasource", "string", false),
52
+ field("FieldName", "string", false),
53
+ field("OrderBy", "number"),
54
+ field("AscDesc", "string"),
55
+ field("GroupBy", "number"),
56
+ field("Alias", "string"),
57
+ field("Aggregate", "string"),
58
+ field("FieldOrder", "number", false),
59
+ ],
60
+ });
61
+ }
62
+ function createProceduresHandler(client) {
63
+ return generateApiDataHandler({
64
+ client,
65
+ resource: CMS_PROCEDURES_VIEW,
66
+ fields: [field("ID", "string", false), field("ProcedureName", "string", false)],
67
+ });
68
+ }
69
+ // ---------------------------------------------------------------------------
70
+ // Data API resource registration
71
+ // ---------------------------------------------------------------------------
72
+ /**
73
+ * Make sure `dbObjectId` is exposed through the data API. Article data sources
74
+ * are resolved by the CMS at runtime, but `af.data.generateApiDataObject` and
75
+ * `af.ProcedureAPI` go through `/api/data`, which only serves registered
76
+ * resources. Mirrors what `af resources add` does.
77
+ */
78
+ async function ensureApiResource(dsApiResources, dbObjectId, cache, dryRun) {
79
+ if (cache.get(dbObjectId)) {
80
+ return "exists";
81
+ }
82
+ let existing = await dsApiResources.retrieve({
83
+ whereClause: `[DBObjectID] = '${escapeSqlString(dbObjectId)}'`,
84
+ maxRecords: 1,
85
+ });
86
+ if (existing.length > 0) {
87
+ cache.set(dbObjectId, true);
88
+ return "exists";
89
+ }
90
+ if (dryRun) {
91
+ cache.set(dbObjectId, true);
92
+ return "would-add";
93
+ }
94
+ await dsApiResources.create({ DBObjectID: dbObjectId });
95
+ cache.set(dbObjectId, true);
96
+ return "added";
97
+ }
98
+ function escapeSqlString(value) {
99
+ return value.replace(/'/g, "''");
100
+ }
101
+ /**
102
+ * The server caches its list of data API resources, so a resource registered a
103
+ * moment ago may still be reported as missing. Retry a few times before failing.
104
+ */
105
+ async function retryOnMissingResource(action, attempts = 5) {
106
+ for (let attempt = 1;; attempt++) {
107
+ try {
108
+ return await action();
109
+ }
110
+ catch (error) {
111
+ let message = error?.message ?? String(error);
112
+ let isStaleResourceCache = /does not exist/i.test(message) || /\b400 Bad Request\b/.test(message);
113
+ if (attempt >= attempts || !isStaleResourceCache) {
114
+ throw error;
115
+ }
116
+ await new Promise((r) => setTimeout(r, 2_000 * attempt));
117
+ }
118
+ }
119
+ }
120
+ // ---------------------------------------------------------------------------
121
+ // CMS record → ResourceEntry
122
+ // ---------------------------------------------------------------------------
123
+ /** Split a comma separated CMS link field list into trimmed field names. */
124
+ function splitList(value) {
125
+ if (!value)
126
+ return [];
127
+ return value
128
+ .split(",")
129
+ .map((v) => v.trim())
130
+ .filter(Boolean);
131
+ }
132
+ function buildLinkFields(datasource) {
133
+ let childFields = splitList(datasource.ChildLinkFields);
134
+ let masterFields = splitList(datasource.MasterLinkFields);
135
+ return childFields.map((childField, index) => {
136
+ let masterField = masterFields[index] ?? childField;
137
+ return masterField === childField ? childField : `${childField}:${masterField}`;
138
+ });
139
+ }
140
+ function dataObjectToEntry(datasource, fields, outputDir, warnings) {
141
+ let sorted = [...fields].sort((a, b) => a.FieldOrder - b.FieldOrder);
142
+ let permissions = `${datasource.AllowInsert ? "I" : ""}${datasource.AllowUpdate ? "U" : ""}${datasource.AllowDelete ? "D" : ""}`;
143
+ let entry = {
144
+ id: datasource.ID,
145
+ resource: datasource.ViewName,
146
+ output: `${outputDir}/${datasource.ID}.ts`,
147
+ global: true,
148
+ types: true,
149
+ expose: true,
150
+ };
151
+ if (permissions)
152
+ entry.permissions = permissions;
153
+ if (datasource.MaxRecords !== 50)
154
+ entry.maxRecords = datasource.MaxRecords;
155
+ if (datasource.WhereClause)
156
+ entry.where = datasource.WhereClause;
157
+ if (datasource.UniqueTableName && datasource.UniqueTableName !== datasource.ViewName) {
158
+ entry.unique = datasource.UniqueTableName;
159
+ }
160
+ if (datasource.DynamicLoading)
161
+ entry.dynamic = true;
162
+ if (datasource.DistinctRows)
163
+ entry.distinct = true;
164
+ if (datasource.MasterID) {
165
+ let linkFields = buildLinkFields(datasource);
166
+ if (linkFields.length > 0) {
167
+ entry.master = datasource.MasterID;
168
+ entry.linkFields = linkFields.length > 1 ? linkFields : linkFields[0];
169
+ }
170
+ else {
171
+ warnings.push(`${datasource.ID}: master '${datasource.MasterID}' has no link fields — master was skipped`);
172
+ }
173
+ }
174
+ let fieldNames = sorted.map((f) => f.FieldName);
175
+ if (fieldNames.length > 0) {
176
+ entry.fields = fieldNames.length > 1 ? fieldNames : fieldNames[0];
177
+ }
178
+ let sortFields = sorted
179
+ .filter((f) => f.OrderBy !== null && f.OrderBy !== undefined)
180
+ .sort((a, b) => a.OrderBy - b.OrderBy);
181
+ if (sortFields.length > 0) {
182
+ entry.sortOrder = sortFields.map((f) => {
183
+ let direction = (f.AscDesc ?? "asc").toLowerCase();
184
+ return direction.startsWith("desc") ? { field: f.FieldName, direction: "desc" } : { field: f.FieldName };
185
+ });
186
+ }
187
+ let groupByFields = sorted
188
+ .filter((f) => f.GroupBy !== null && f.GroupBy !== undefined)
189
+ .sort((a, b) => a.GroupBy - b.GroupBy);
190
+ if (groupByFields.length > 0) {
191
+ entry.groupBy = groupByFields.map((f) => f.FieldName);
192
+ }
193
+ let aggregateFields = sorted.filter((f) => !!f.Aggregate);
194
+ if (aggregateFields.length > 0) {
195
+ entry.aggregates = aggregateFields.map((f) => ({ field: f.FieldName, aggregate: f.Aggregate }));
196
+ }
197
+ let aliased = sorted.filter((f) => f.Alias && f.Alias !== f.FieldName);
198
+ if (aliased.length > 0) {
199
+ warnings.push(`${datasource.ID}: field aliases are not supported by resources.yaml (${aliased
200
+ .map((f) => `${f.FieldName} → ${f.Alias}`)
201
+ .join(", ")})`);
202
+ }
203
+ return entry;
204
+ }
205
+ function procedureToEntry(procedure, outputDir) {
206
+ return {
207
+ id: procedure.ID,
208
+ resource: procedure.ProcedureName,
209
+ output: `${outputDir}/${procedure.ID}.ts`,
210
+ global: true,
211
+ types: true,
212
+ expose: true,
213
+ };
214
+ }
215
+ /** Replace an entry with the same id, or append it when it is new. */
216
+ function upsertEntry(entries, entry) {
217
+ let index = entries.findIndex((e) => e.id === entry.id);
218
+ if (index === -1) {
219
+ entries.push(entry);
220
+ return "added";
221
+ }
222
+ entries[index] = { ...entries[index], ...entry };
223
+ return "updated";
224
+ }
225
+ export async function migrateArticleResources(id, options = {}) {
226
+ let { hostname, username, password, appframe } = await getLoginInfo();
227
+ let articleId = appframe.article?.id ?? appframe.article;
228
+ let articleHost = appframe.article?.hostname ?? hostname;
229
+ if (!articleId) {
230
+ throw new Error("No article id found in package.json (appframe.article.id)");
231
+ }
232
+ let outputDir = (options.outputDir ?? "src/data").replace(/\\/g, "/").replace(/\/$/, "");
233
+ let dryRun = options.dryRun ?? false;
234
+ let client = new Client(hostname);
235
+ await client.login(username, password);
236
+ let dsApiResources = createApiResourcesHandler(client);
237
+ let registered = new Map();
238
+ // The CMS configuration views are not exposed through the data API by default.
239
+ for (let view of [CMS_DATASOURCES_VIEW, CMS_DATASOURCE_FIELDS_VIEW, CMS_PROCEDURES_VIEW]) {
240
+ let result = await ensureApiResource(dsApiResources, view, registered, false);
241
+ if (result === "added") {
242
+ console.log(`Registered '${view}' as a data API resource`);
243
+ }
244
+ }
245
+ let articleFilter = `[HostName] = '${escapeSqlString(articleHost)}' AND [ArticleId] = '${escapeSqlString(articleId)}'`;
246
+ let [datasources, datasourceFields, procedures] = await retryOnMissingResource(() => Promise.all([
247
+ createDatasourcesHandler(client).retrieve({
248
+ whereClause: `${articleFilter} AND [ArticleVersion] IS NULL`,
249
+ maxRecords: -1,
250
+ }),
251
+ createDatasourceFieldsHandler(client).retrieve({
252
+ whereClause: articleFilter,
253
+ maxRecords: -1,
254
+ }),
255
+ createProceduresHandler(client).retrieve({
256
+ whereClause: `${articleFilter} AND [ArticleVersion] IS NULL`,
257
+ maxRecords: -1,
258
+ }),
259
+ ]));
260
+ if (id) {
261
+ datasources = datasources.filter((d) => d.ID.toLowerCase() === id.toLowerCase());
262
+ procedures = procedures.filter((p) => p.ID.toLowerCase() === id.toLowerCase());
263
+ if (datasources.length === 0 && procedures.length === 0) {
264
+ throw new Error(`No data object or procedure named '${id}' found on article '${articleId}' (${articleHost})`);
265
+ }
266
+ }
267
+ if (datasources.length === 0 && procedures.length === 0) {
268
+ console.log(`No article data objects or procedures found on '${articleId}' (${articleHost})`);
269
+ return;
270
+ }
271
+ let warnings = [];
272
+ let migrated = [];
273
+ for (let datasource of datasources) {
274
+ if (!datasource.ViewName) {
275
+ warnings.push(`${datasource.ID}: no view name — skipped`);
276
+ continue;
277
+ }
278
+ let fields = datasourceFields.filter((f) => f.Datasource === datasource.ID);
279
+ migrated.push({ type: "dataObject", entry: dataObjectToEntry(datasource, fields, outputDir, warnings) });
280
+ }
281
+ for (let procedure of procedures) {
282
+ migrated.push({ type: "procedure", entry: procedureToEntry(procedure, outputDir) });
283
+ }
284
+ // Every migrated resource has to be reachable through /api/data.
285
+ for (let { entry } of migrated) {
286
+ let result = await ensureApiResource(dsApiResources, entry.resource, registered, dryRun);
287
+ if (result === "added") {
288
+ console.log(`Registered '${entry.resource}' as a data API resource`);
289
+ }
290
+ else if (result === "would-add") {
291
+ console.log(`Would register '${entry.resource}' as a data API resource`);
292
+ }
293
+ }
294
+ let resourcesConfig = await readResourcesConfig(options.config);
295
+ let dataObjects = resourcesConfig.dataObjects ?? [];
296
+ let procedureEntries = resourcesConfig.procedures ?? [];
297
+ for (let { type, entry } of migrated) {
298
+ let target = type === "dataObject" ? dataObjects : procedureEntries;
299
+ let result = upsertEntry(target, entry);
300
+ console.log(` ${dryRun ? "○" : "✓"} ${entry.id} → ${entry.resource} (${result})`);
301
+ }
302
+ if (dataObjects.length > 0)
303
+ resourcesConfig.dataObjects = dataObjects;
304
+ if (procedureEntries.length > 0)
305
+ resourcesConfig.procedures = procedureEntries;
306
+ if (dryRun) {
307
+ for (let warning of warnings) {
308
+ console.warn(` ! ${warning}`);
309
+ }
310
+ console.log(`\nDry run — resources.yaml was not modified (${migrated.length} resource(s) would be migrated)`);
311
+ return;
312
+ }
313
+ await writeResourcesConfig(resourcesConfig, options.config);
314
+ console.log(`\nUpdated resources.yaml with ${migrated.length} resource(s)`);
315
+ if (options.generate !== false) {
316
+ let errors = [];
317
+ for (let { entry } of migrated) {
318
+ let cliOptions = entryToCLIOptions(entry, resourcesConfig.server ?? hostname);
319
+ let outputPath = resolve(process.cwd(), entry.output);
320
+ cliOptions.output = outputPath;
321
+ try {
322
+ let content = await retryOnMissingResource(() => fetchAndGenerate(entry.resource, cliOptions, client));
323
+ await writeGeneratedFile(outputPath, content);
324
+ console.log(` ✓ generated ${entry.output}`);
325
+ }
326
+ catch (error) {
327
+ let message = error?.message ?? String(error);
328
+ console.error(` ✗ ${entry.id}: ${message}`);
329
+ errors.push(`${entry.id}: ${message}`);
330
+ }
331
+ }
332
+ if (errors.length > 0) {
333
+ throw new Error(`${errors.length} resource(s) failed to generate:\n${errors.map((e) => ` ${e}`).join("\n")}`);
334
+ }
335
+ }
336
+ for (let warning of warnings) {
337
+ console.warn(` ! ${warning}`);
338
+ }
339
+ console.log("\nRemember to import the generated files from your app, remove the migrated data objects and procedures\n" +
340
+ "from the article in the appdesigner, and run 'pnpm generate-types' afterwards.");
341
+ }
package/lib/cli.js CHANGED
@@ -49,6 +49,23 @@ resources
49
49
  let { addResource } = await import("./cli-resources-add.js");
50
50
  await addResource();
51
51
  });
52
+ resources
53
+ .command("migrate [id]")
54
+ .description("Migrate article data objects and procedures from the appdesigner to resources.yaml")
55
+ .option("-c, --config <path>", "Path to resources config file (default: resources.yaml)")
56
+ .option("-o, --output-dir <path>", "Directory for generated files (default: src/data)")
57
+ .option("--dry-run", "Show what would be migrated without writing anything")
58
+ .option("--no-generate", "Only update resources.yaml, do not generate the output files")
59
+ .action(async (id, opts) => {
60
+ let { migrateArticleResources } = await import("./cli-resources-migrate.js");
61
+ try {
62
+ await migrateArticleResources(id, opts);
63
+ }
64
+ catch (error) {
65
+ console.error(`\n${error?.message ?? error}`);
66
+ process.exit(1);
67
+ }
68
+ });
52
69
  resources
53
70
  .command("edit [id]")
54
71
  .description("Interactively edit an existing resource in resources.yaml")
package/lib/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Plugin } from "vite";
2
+ import { appframeDataGlobal } from "./appframeDataGlobal.js";
2
3
  import { addAppframeBuildConfig } from "./build.js";
3
4
  import { createDevMiddleware } from "./devServer.js";
4
5
  import { publishSafeOutput } from "./publishSafeOutput.js";
@@ -19,6 +20,14 @@ export interface AppframePluginOptions {
19
20
  * @default true
20
21
  */
21
22
  devtools?: boolean;
23
+ /**
24
+ * Whether to resolve `@olenbetong/appframe-data` to the `af.data` global the article
25
+ * loads, instead of bundling a second copy of the package. Set to `false` only when
26
+ * running outside an article context, where the global does not exist.
27
+ *
28
+ * @default true
29
+ */
30
+ dataGlobal?: boolean;
22
31
  }
23
32
  export default function appframe(options?: AppframePluginOptions): Plugin[];
24
- export { addAppframeBuildConfig, createDevMiddleware, publishSafeOutput };
33
+ export { addAppframeBuildConfig, appframeDataGlobal, createDevMiddleware, publishSafeOutput };
package/lib/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { resolve } from "node:path";
2
2
  import bodyParser from "body-parser";
3
3
  import { watch } from "chokidar";
4
+ import { appframeDataGlobal } from "./appframeDataGlobal.js";
4
5
  import { addAppframeBuildConfig } from "./build.js";
5
6
  import { generateFromConfig } from "./cli-resources-generate.js";
6
7
  import { createDevMiddleware, getLoginInfo, getProxyRoutes } from "./devServer.js";
7
8
  import { createDevtoolsMiddleware } from "./devtoolsServer.js";
8
9
  import { runGenerateTypes } from "./generateTypes.js";
10
+ import { getMuiXLicenseKey } from "./licenses.js";
9
11
  import { localizeMiddleware } from "./localization.js";
10
12
  import { checkSession, getLastSession, login } from "./proxy.js";
11
13
  import { RESOURCES_CONFIG_FILE } from "./resourcesConfig.js";
@@ -34,7 +36,7 @@ catch (error) {
34
36
  }
35
37
  const jsonParser = bodyParser.json();
36
38
  export default function appframe(options = {}) {
37
- let { generateTypes = true, devtools = true } = options;
39
+ let { generateTypes = true, devtools = true, dataGlobal = true } = options;
38
40
  let plugin = {
39
41
  name: "appframe",
40
42
  resolveId(source) {
@@ -90,6 +92,30 @@ export default function appframe(options = {}) {
90
92
  resolve: {
91
93
  ...currentConfig.resolve,
92
94
  alias: [{ find: "~/", replacement: "/src/" }],
95
+ /*
96
+ * Apps depend on the published `@olenbetong/*` packages, while workspace
97
+ * packages (e.g. appframe-ds) depend on their workspace siblings. Without
98
+ * deduplication the production build ends up with two copies of, for
99
+ * instance, appframe-react, which means two distinct `DataObjectContext`
100
+ * instances: `DataObjectProvider` writes to one, `useField` reads the
101
+ * empty default of the other and crashes with "currentRow is not a
102
+ * function". The dev server hides this because linked packages resolve to
103
+ * the same optimized dependency.
104
+ */
105
+ dedupe: [
106
+ ...(currentConfig.resolve?.dedupe ?? []),
107
+ "@olenbetong/appframe-core",
108
+ "@olenbetong/appframe-data",
109
+ "@olenbetong/appframe-ds",
110
+ "@olenbetong/appframe-mui",
111
+ "@olenbetong/appframe-react",
112
+ ],
113
+ },
114
+ define: {
115
+ ...currentConfig.define,
116
+ // Lets `@olenbetong/appframe-ds/grid` register the MUI X license
117
+ // itself, so apps don't have to call `LicenseInfo.setLicenseKey`.
118
+ __MUI_X_LICENSE_KEY__: JSON.stringify(getMuiXLicenseKey()),
93
119
  },
94
120
  };
95
121
  if (command === "build") {
@@ -214,6 +240,6 @@ export default function appframe(options = {}) {
214
240
  ];
215
241
  },
216
242
  };
217
- return [plugin, publishSafeOutput()];
243
+ return [...(dataGlobal ? [appframeDataGlobal()] : []), plugin, publishSafeOutput()];
218
244
  }
219
- export { addAppframeBuildConfig, createDevMiddleware, publishSafeOutput };
245
+ export { addAppframeBuildConfig, appframeDataGlobal, createDevMiddleware, publishSafeOutput };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Reads the MUI X license key that should be injected into the bundle.
3
+ *
4
+ * The key is looked for in this order:
5
+ *
6
+ * 1. The `MUI_X_LICENSE_KEY` environment variable, so it can be supplied by CI
7
+ * or a local `.env` without being committed.
8
+ * 2. The `MUI_X_LICENSE_KEY` constant exported from the app's
9
+ * `src/shared/licenses.ts` — the `shared` folder symlinked into every app.
10
+ *
11
+ * Returns an empty string when no key is found. `@olenbetong/appframe-ds/grid`
12
+ * then falls back to `import.meta.env.VITE_MUI_X_LICENSE_KEY`, and warns in dev
13
+ * if that is missing too.
14
+ */
15
+ export declare function getMuiXLicenseKey(root?: string): string;
@@ -0,0 +1,35 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ /**
4
+ * Reads the MUI X license key that should be injected into the bundle.
5
+ *
6
+ * The key is looked for in this order:
7
+ *
8
+ * 1. The `MUI_X_LICENSE_KEY` environment variable, so it can be supplied by CI
9
+ * or a local `.env` without being committed.
10
+ * 2. The `MUI_X_LICENSE_KEY` constant exported from the app's
11
+ * `src/shared/licenses.ts` — the `shared` folder symlinked into every app.
12
+ *
13
+ * Returns an empty string when no key is found. `@olenbetong/appframe-ds/grid`
14
+ * then falls back to `import.meta.env.VITE_MUI_X_LICENSE_KEY`, and warns in dev
15
+ * if that is missing too.
16
+ */
17
+ export function getMuiXLicenseKey(root = process.cwd()) {
18
+ let fromEnvironment = process.env.MUI_X_LICENSE_KEY?.trim();
19
+ if (fromEnvironment) {
20
+ return fromEnvironment;
21
+ }
22
+ for (let candidate of ["src/shared/licenses.ts", "shared/licenses.ts"]) {
23
+ try {
24
+ let source = readFileSync(resolve(root, candidate), "utf-8");
25
+ let match = source.match(/MUI_X_LICENSE_KEY\s*=\s*["'`]([^"'`]+)["'`]/);
26
+ if (match) {
27
+ return match[1];
28
+ }
29
+ }
30
+ catch {
31
+ // Try the next candidate.
32
+ }
33
+ }
34
+ return "";
35
+ }
@@ -579,7 +579,8 @@ export async function fetchResourceDefinition(client, resourceName) {
579
579
  }),
580
580
  });
581
581
  if (!response.ok) {
582
- throw new Error(`Failed to fetch resource definition for '${resourceName}': ${response.status} ${response.statusText}`);
582
+ let details = await response.text().catch(() => "");
583
+ throw new Error(`Failed to fetch resource definition for '${resourceName}': ${response.status} ${response.statusText}${details ? ` — ${details}` : ""}`);
583
584
  }
584
585
  const json = await response.json();
585
586
  return json.success ?? json;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olenbetong/appframe-vite",
3
- "version": "6.5.0",
3
+ "version": "6.7.0",
4
4
  "description": "Tools to use and deploy Vite applications to Appframe",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",
@@ -29,25 +29,25 @@
29
29
  "author": "Bjørnar Vister Hansen <bvh@olenbetong.no>",
30
30
  "license": "MIT",
31
31
  "dependencies": {
32
- "@olenbetong/appframe-data": "1.5.0",
33
32
  "body-parser": "^2.3.0",
34
- "chalk": "5.6.2",
33
+ "chalk": "6.0.0",
35
34
  "chokidar": "^5.0.0",
36
35
  "commander": "15.0.0",
37
36
  "dotenv": "^17.4.2",
38
37
  "fuzzy": "^0.1.3",
39
38
  "inquirer": "^14.0.2",
40
39
  "inquirer-autocomplete-standalone": "^0.8.1",
41
- "jsdom": "29.1.1",
40
+ "jsdom": "30.0.1",
42
41
  "rollup-plugin-visualizer": "^7.0.1",
43
42
  "yaml": "^2.9.0",
43
+ "@olenbetong/appframe-data": "1.6.1",
44
44
  "@olenbetong/appframe-devtools": "0.3.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/jsdom": "^27.0.0",
48
- "@types/node": "26.1.2",
48
+ "@types/node": "26.2.0",
49
49
  "typescript": "7.0.2",
50
- "vite": "8.2.0"
50
+ "vite": "8.2.1"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "vite": ">=8.1.5"