@jskit-ai/kernel 0.1.19 → 0.1.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/kernel",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "typebox": "^1.0.81"
@@ -34,6 +34,7 @@
34
34
  "./shared/support/tokens": "./shared/support/tokens.js",
35
35
  "./shared/support/normalize": "./shared/support/normalize.js",
36
36
  "./shared/support/permissions": "./shared/support/permissions.js",
37
+ "./shared/support/crudLookup": "./shared/support/crudLookup.js",
37
38
  "./shared/support/deepFreeze": "./shared/support/deepFreeze.js",
38
39
  "./shared/support/listenerSet": "./shared/support/listenerSet.js",
39
40
  "./shared/support/providerLogger": "./shared/support/providerLogger.js",
@@ -0,0 +1,61 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath, pathToFileURL } from "node:url";
3
+ import { fileExists } from "../../internal/node/fileSystem.js";
4
+
5
+ const PUBLIC_CONFIG_RELATIVE_PATH = "config/public.js";
6
+ const SERVER_CONFIG_RELATIVE_PATH = "config/server.js";
7
+
8
+ function normalizeConfigObject(value) {
9
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
10
+ }
11
+
12
+ async function resolveAppRootFromModuleUrl(
13
+ moduleUrl,
14
+ { publicConfigRelativePath = PUBLIC_CONFIG_RELATIVE_PATH } = {}
15
+ ) {
16
+ const moduleDirectory = path.dirname(fileURLToPath(moduleUrl));
17
+ let currentDirectory = path.resolve(moduleDirectory);
18
+
19
+ while (true) {
20
+ const candidateConfigPath = path.join(currentDirectory, publicConfigRelativePath);
21
+ if (await fileExists(candidateConfigPath)) {
22
+ return currentDirectory;
23
+ }
24
+
25
+ const parentDirectory = path.dirname(currentDirectory);
26
+ if (parentDirectory === currentDirectory) {
27
+ throw new Error(`Unable to locate app root (missing ${publicConfigRelativePath}).`);
28
+ }
29
+ currentDirectory = parentDirectory;
30
+ }
31
+ }
32
+
33
+ async function loadConfigModuleAtPath(absolutePath) {
34
+ if (!(await fileExists(absolutePath))) {
35
+ return {};
36
+ }
37
+
38
+ const loadedModule = await import(pathToFileURL(absolutePath).href);
39
+ return normalizeConfigObject(loadedModule?.config);
40
+ }
41
+
42
+ async function loadAppConfigFromModuleUrl({
43
+ moduleUrl = import.meta.url,
44
+ publicConfigRelativePath = PUBLIC_CONFIG_RELATIVE_PATH,
45
+ serverConfigRelativePath = SERVER_CONFIG_RELATIVE_PATH
46
+ } = {}) {
47
+ const appRoot = await resolveAppRootFromModuleUrl(moduleUrl, {
48
+ publicConfigRelativePath
49
+ });
50
+ const [publicConfig, serverConfig] = await Promise.all([
51
+ loadConfigModuleAtPath(path.join(appRoot, publicConfigRelativePath)),
52
+ loadConfigModuleAtPath(path.join(appRoot, serverConfigRelativePath))
53
+ ]);
54
+
55
+ return Object.freeze({
56
+ ...publicConfig,
57
+ ...serverConfig
58
+ });
59
+ }
60
+
61
+ export { loadAppConfigFromModuleUrl };
@@ -0,0 +1,55 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import test from "node:test";
6
+ import { pathToFileURL } from "node:url";
7
+ import { loadAppConfigFromModuleUrl } from "./appConfigFiles.js";
8
+
9
+ async function createModuleUrlAt(absolutePath) {
10
+ await mkdir(path.dirname(absolutePath), { recursive: true });
11
+ await writeFile(absolutePath, "export {};\n", "utf8");
12
+ return pathToFileURL(absolutePath).href;
13
+ }
14
+
15
+ test("loadAppConfigFromModuleUrl merges public and server config", async () => {
16
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "kernel-app-config-"));
17
+ const appRoot = path.join(tempRoot, "app");
18
+ await mkdir(path.join(appRoot, "config"), { recursive: true });
19
+ await writeFile(path.join(appRoot, "config", "public.js"), "export const config = { a: 1, shared: 'public' };", "utf8");
20
+ await writeFile(path.join(appRoot, "config", "server.js"), "export const config = { b: 2, shared: 'server' };", "utf8");
21
+ const moduleUrl = await createModuleUrlAt(path.join(appRoot, "packages", "main", "src", "server", "providers", "MainServiceProvider.js"));
22
+
23
+ const loaded = await loadAppConfigFromModuleUrl({ moduleUrl });
24
+
25
+ assert.deepEqual(loaded, {
26
+ a: 1,
27
+ b: 2,
28
+ shared: "server"
29
+ });
30
+ assert.equal(Object.isFrozen(loaded), true);
31
+ });
32
+
33
+ test("loadAppConfigFromModuleUrl tolerates missing server config", async () => {
34
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "kernel-app-config-"));
35
+ const appRoot = path.join(tempRoot, "app");
36
+ await mkdir(path.join(appRoot, "config"), { recursive: true });
37
+ await writeFile(path.join(appRoot, "config", "public.js"), "export const config = { a: 1 };", "utf8");
38
+ const moduleUrl = await createModuleUrlAt(path.join(appRoot, "packages", "main", "src", "server", "providers", "MainServiceProvider.js"));
39
+
40
+ const loaded = await loadAppConfigFromModuleUrl({ moduleUrl });
41
+
42
+ assert.deepEqual(loaded, {
43
+ a: 1
44
+ });
45
+ });
46
+
47
+ test("loadAppConfigFromModuleUrl throws when app root cannot be resolved", async () => {
48
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "kernel-app-config-"));
49
+ const moduleUrl = await createModuleUrlAt(path.join(tempRoot, "missing", "packages", "main", "src", "server", "providers", "MainServiceProvider.js"));
50
+
51
+ await assert.rejects(
52
+ loadAppConfigFromModuleUrl({ moduleUrl }),
53
+ /Unable to locate app root/
54
+ );
55
+ });
@@ -1,2 +1,8 @@
1
1
  export { symlinkSafeRequire } from "./symlinkSafeRequire.js";
2
2
  export { resolveAppConfig } from "./appConfig.js";
3
+ export { loadAppConfigFromModuleUrl } from "./appConfigFiles.js";
4
+ export { resolveRequiredAppRoot, toPosixPath } from "./path.js";
5
+ export {
6
+ discoverShellOutletTargetsFromApp,
7
+ resolveShellOutletPlacementTargetFromApp
8
+ } from "./shellOutlets.js";
@@ -0,0 +1,20 @@
1
+ import path from "node:path";
2
+ import { normalizeText } from "../../shared/support/normalize.js";
3
+
4
+ function toPosixPath(value = "") {
5
+ return String(value || "").replaceAll(path.sep, "/");
6
+ }
7
+
8
+ function resolveRequiredAppRoot(appRoot, { context = "operation" } = {}) {
9
+ const normalizedAppRoot = normalizeText(appRoot);
10
+ if (!normalizedAppRoot) {
11
+ throw new Error(`${normalizeText(context) || "operation"} requires appRoot.`);
12
+ }
13
+
14
+ return path.resolve(normalizedAppRoot);
15
+ }
16
+
17
+ export {
18
+ resolveRequiredAppRoot,
19
+ toPosixPath
20
+ };
@@ -0,0 +1,259 @@
1
+ import path from "node:path";
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import { loadInstalledPackageDescriptor } from "../../internal/node/installedPackageDescriptor.js";
4
+ import { normalizeObject, normalizeText } from "../../shared/support/normalize.js";
5
+ import { resolveRequiredAppRoot, toPosixPath } from "./path.js";
6
+ import {
7
+ describeShellOutletTargets,
8
+ discoverShellOutletTargetsFromVueSource,
9
+ findShellOutletTargetById,
10
+ normalizeShellOutletTargetId
11
+ } from "../../shared/support/shellLayoutTargets.js";
12
+
13
+ const VUE_DISCOVERY_IGNORED_ERROR_CODES = new Set(["ENOENT", "ENOTDIR", "EACCES", "EPERM"]);
14
+ const LOCK_FILE_RELATIVE_PATH = ".jskit/lock.json";
15
+
16
+ async function collectVueFilePaths(rootDirectoryPath) {
17
+ const absoluteRoot = path.resolve(String(rootDirectoryPath || ""));
18
+ const stack = [absoluteRoot];
19
+ const files = [];
20
+
21
+ while (stack.length > 0) {
22
+ const currentDirectory = stack.pop();
23
+ let directoryEntries = [];
24
+ try {
25
+ directoryEntries = await readdir(currentDirectory, { withFileTypes: true });
26
+ } catch (error) {
27
+ const errorCode = normalizeText(error?.code).toUpperCase();
28
+ if (!VUE_DISCOVERY_IGNORED_ERROR_CODES.has(errorCode)) {
29
+ throw error;
30
+ }
31
+ continue;
32
+ }
33
+
34
+ for (const entry of directoryEntries) {
35
+ const entryPath = path.join(currentDirectory, entry.name);
36
+ if (entry.isDirectory()) {
37
+ stack.push(entryPath);
38
+ continue;
39
+ }
40
+
41
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".vue")) {
42
+ continue;
43
+ }
44
+
45
+ files.push(entryPath);
46
+ }
47
+ }
48
+
49
+ return files.sort((left, right) => left.localeCompare(right));
50
+ }
51
+
52
+ async function readInstalledPackageStates(appRoot) {
53
+ const lockPath = path.resolve(appRoot, LOCK_FILE_RELATIVE_PATH);
54
+ let lockSource = "";
55
+ try {
56
+ lockSource = await readFile(lockPath, "utf8");
57
+ } catch (error) {
58
+ const errorCode = normalizeText(error?.code).toUpperCase();
59
+ if (errorCode === "ENOENT") {
60
+ return {};
61
+ }
62
+ throw error;
63
+ }
64
+
65
+ let lockPayload = {};
66
+ try {
67
+ lockPayload = JSON.parse(lockSource);
68
+ } catch (error) {
69
+ throw new Error(`Invalid JSON in ${LOCK_FILE_RELATIVE_PATH}.`);
70
+ }
71
+
72
+ return normalizeObject(lockPayload.installedPackages);
73
+ }
74
+
75
+ function normalizePackageOutletTarget({
76
+ packageId = "",
77
+ outlet = {},
78
+ descriptorPath = ""
79
+ } = {}) {
80
+ const normalizedPackageId = normalizeText(packageId);
81
+ if (!normalizedPackageId) {
82
+ return null;
83
+ }
84
+
85
+ const outletRecord = normalizeObject(outlet);
86
+ const outletTargetId = normalizeShellOutletTargetId(
87
+ `${normalizeText(outletRecord.host)}:${normalizeText(outletRecord.position)}`
88
+ );
89
+ if (!outletTargetId) {
90
+ return null;
91
+ }
92
+
93
+ const separatorIndex = outletTargetId.indexOf(":");
94
+ const host = outletTargetId.slice(0, separatorIndex);
95
+ const position = outletTargetId.slice(separatorIndex + 1);
96
+ const source = normalizeText(outletRecord.source);
97
+ const sourcePath = source
98
+ ? `package:${normalizedPackageId}:${toPosixPath(source)}`
99
+ : `package:${normalizedPackageId}${descriptorPath ? `:${toPosixPath(descriptorPath)}` : ""}`;
100
+
101
+ return Object.freeze({
102
+ id: outletTargetId,
103
+ host,
104
+ position,
105
+ default: false,
106
+ sourcePath,
107
+ sourcePackageId: normalizedPackageId
108
+ });
109
+ }
110
+
111
+ async function collectInstalledPackageOutletTargets(appRoot) {
112
+ const installedPackageStates = await readInstalledPackageStates(appRoot);
113
+ const packageIds = Object.keys(installedPackageStates).sort((left, right) => left.localeCompare(right));
114
+ const targets = [];
115
+
116
+ for (const packageId of packageIds) {
117
+ const installedPackageState = normalizeObject(installedPackageStates[packageId]);
118
+ const descriptorRecord = await loadInstalledPackageDescriptor({
119
+ appRoot,
120
+ packageId,
121
+ installedPackageState
122
+ });
123
+ const descriptor = normalizeObject(descriptorRecord.descriptor);
124
+ const metadata = normalizeObject(descriptor.metadata);
125
+ const ui = normalizeObject(metadata.ui);
126
+ const placements = normalizeObject(ui.placements);
127
+ const outlets = Array.isArray(placements.outlets) ? placements.outlets : [];
128
+ for (const outlet of outlets) {
129
+ const normalizedTarget = normalizePackageOutletTarget({
130
+ packageId,
131
+ outlet,
132
+ descriptorPath: descriptorRecord.descriptorPath
133
+ });
134
+ if (normalizedTarget) {
135
+ targets.push(normalizedTarget);
136
+ }
137
+ }
138
+ }
139
+
140
+ return targets;
141
+ }
142
+
143
+ async function discoverShellOutletTargetsFromApp({ appRoot, sourceRoot = "src" } = {}) {
144
+ const resolvedAppRoot = resolveRequiredAppRoot(appRoot, {
145
+ context: "discoverShellOutletTargetsFromApp"
146
+ });
147
+
148
+ const sourceDirectory = path.resolve(resolvedAppRoot, String(sourceRoot || "src"));
149
+ const targetById = new Map();
150
+ let defaultTargetId = "";
151
+ let defaultTargetSource = "";
152
+ const vueFiles = await collectVueFilePaths(sourceDirectory);
153
+
154
+ for (const absoluteFilePath of vueFiles) {
155
+ const relativePath = toPosixPath(path.relative(resolvedAppRoot, absoluteFilePath));
156
+ const source = await readFile(absoluteFilePath, "utf8");
157
+ if (!source.includes("<ShellOutlet")) {
158
+ continue;
159
+ }
160
+
161
+ const discovered = discoverShellOutletTargetsFromVueSource(source, {
162
+ context: relativePath
163
+ });
164
+ const targets = Array.isArray(discovered.targets) ? discovered.targets : [];
165
+ for (const target of targets) {
166
+ if (!targetById.has(target.id)) {
167
+ targetById.set(
168
+ target.id,
169
+ Object.freeze({
170
+ ...target,
171
+ sourcePath: relativePath
172
+ })
173
+ );
174
+ }
175
+ }
176
+
177
+ const discoveredDefaultTargetId = normalizeShellOutletTargetId(discovered.defaultTargetId);
178
+ if (!discoveredDefaultTargetId) {
179
+ continue;
180
+ }
181
+
182
+ if (defaultTargetId && discoveredDefaultTargetId !== defaultTargetId) {
183
+ throw new Error(
184
+ `Multiple default ShellOutlet targets found in app source: "${defaultTargetId}" (${defaultTargetSource}) and ` +
185
+ `"${discoveredDefaultTargetId}" (${relativePath}).`
186
+ );
187
+ }
188
+
189
+ defaultTargetId = discoveredDefaultTargetId;
190
+ defaultTargetSource = relativePath;
191
+ }
192
+
193
+ const packageTargets = await collectInstalledPackageOutletTargets(resolvedAppRoot);
194
+ for (const target of packageTargets) {
195
+ if (!targetById.has(target.id)) {
196
+ targetById.set(target.id, target);
197
+ }
198
+ }
199
+
200
+ const targets = [...targetById.values()].sort((left, right) => left.id.localeCompare(right.id));
201
+ const normalizedTargets = targets.map((target) =>
202
+ Object.freeze({
203
+ ...target,
204
+ default: target.id === defaultTargetId
205
+ })
206
+ );
207
+
208
+ return Object.freeze({
209
+ targets: Object.freeze(normalizedTargets),
210
+ defaultTargetId
211
+ });
212
+ }
213
+
214
+ async function resolveShellOutletPlacementTargetFromApp({ appRoot, placement = "", context = "ui-generator" } = {}) {
215
+ const resolvedContext = normalizeText(context) || "ui-generator";
216
+ const requestedPlacementOption = normalizeText(placement);
217
+ const requestedPlacementTargetId = normalizeShellOutletTargetId(requestedPlacementOption);
218
+ if (requestedPlacementOption && !requestedPlacementTargetId) {
219
+ throw new Error(`${resolvedContext} option "placement" must be in "host:position" format.`);
220
+ }
221
+
222
+ const discovered = await discoverShellOutletTargetsFromApp({ appRoot, sourceRoot: "src" });
223
+ const targets = Array.isArray(discovered.targets) ? discovered.targets : [];
224
+ if (targets.length < 1) {
225
+ throw new Error(
226
+ `${resolvedContext} could not find any placement targets from app Vue outlets or installed package metadata.`
227
+ );
228
+ }
229
+
230
+ if (requestedPlacementTargetId) {
231
+ const requestedTarget = findShellOutletTargetById(targets, requestedPlacementTargetId);
232
+ if (!requestedTarget) {
233
+ const availableTargets = describeShellOutletTargets(targets);
234
+ throw new Error(
235
+ `${resolvedContext} option "placement" target "${requestedPlacementTargetId}" is not declared in app or package placement outlets. ` +
236
+ `Available targets: ${availableTargets || "<none>"}.`
237
+ );
238
+ }
239
+
240
+ return requestedTarget;
241
+ }
242
+
243
+ const defaultTarget = findShellOutletTargetById(targets, discovered.defaultTargetId);
244
+ if (defaultTarget) {
245
+ return defaultTarget;
246
+ }
247
+
248
+ const availableTargets = describeShellOutletTargets(targets);
249
+ throw new Error(
250
+ `${resolvedContext} could not resolve a default ShellOutlet target from app Vue outlets. ` +
251
+ `Set one outlet as default (e.g. <ShellOutlet host="shell-layout" position="primary-menu" default />) ` +
252
+ `or pass "--placement host:position". Available targets: ${availableTargets || "<none>"}.`
253
+ );
254
+ }
255
+
256
+ export {
257
+ discoverShellOutletTargetsFromApp,
258
+ resolveShellOutletPlacementTargetFromApp
259
+ };
@@ -0,0 +1,270 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import test from "node:test";
6
+ import {
7
+ discoverShellOutletTargetsFromApp,
8
+ resolveShellOutletPlacementTargetFromApp
9
+ } from "./shellOutlets.js";
10
+
11
+ async function withTempApp(run) {
12
+ const appRoot = await mkdtemp(path.join(tmpdir(), "kernel-shell-outlets-"));
13
+ try {
14
+ return await run(appRoot);
15
+ } finally {
16
+ await rm(appRoot, { recursive: true, force: true });
17
+ }
18
+ }
19
+
20
+ async function writeFileInApp(appRoot, relativePath, source) {
21
+ const absoluteFilePath = path.join(appRoot, relativePath);
22
+ await mkdir(path.dirname(absoluteFilePath), { recursive: true });
23
+ await writeFile(absoluteFilePath, source, "utf8");
24
+ }
25
+
26
+ test("resolveShellOutletPlacementTargetFromApp reads outlets across app Vue files", async () => {
27
+ await withTempApp(async (appRoot) => {
28
+ await writeFileInApp(
29
+ appRoot,
30
+ "src/components/ShellLayout.vue",
31
+ `<template>
32
+ <div>
33
+ <ShellOutlet host="shell-layout" position="primary-menu" />
34
+ <ShellOutlet host="shell-layout" position="top-right" />
35
+ </div>
36
+ </template>
37
+ `
38
+ );
39
+ await writeFileInApp(
40
+ appRoot,
41
+ "src/pages/admin/workspace/settings/index.vue",
42
+ `<template>
43
+ <section>
44
+ <ShellOutlet host="workspace-settings" position="forms" default />
45
+ </section>
46
+ </template>
47
+ `
48
+ );
49
+
50
+ const target = await resolveShellOutletPlacementTargetFromApp({
51
+ appRoot,
52
+ context: "ui-generator"
53
+ });
54
+
55
+ assert.equal(target.host, "workspace-settings");
56
+ assert.equal(target.position, "forms");
57
+ });
58
+ });
59
+
60
+ test("discoverShellOutletTargetsFromApp includes installed package placement outlets", async () => {
61
+ await withTempApp(async (appRoot) => {
62
+ await writeFileInApp(
63
+ appRoot,
64
+ "src/components/ShellLayout.vue",
65
+ `<template>
66
+ <div>
67
+ <ShellOutlet host="shell-layout" position="primary-menu" default />
68
+ </div>
69
+ </template>
70
+ `
71
+ );
72
+ await writeFileInApp(
73
+ appRoot,
74
+ ".jskit/lock.json",
75
+ `${JSON.stringify(
76
+ {
77
+ lockVersion: 1,
78
+ installedPackages: {
79
+ "@example/users-web": {
80
+ packageId: "@example/users-web",
81
+ source: {
82
+ type: "npm-installed-package",
83
+ descriptorPath: "node_modules/@example/users-web/package.descriptor.mjs"
84
+ }
85
+ }
86
+ }
87
+ },
88
+ null,
89
+ 2
90
+ )}\n`
91
+ );
92
+ await writeFileInApp(
93
+ appRoot,
94
+ "node_modules/@example/users-web/package.descriptor.mjs",
95
+ `export default {
96
+ packageId: "@example/users-web",
97
+ metadata: {
98
+ ui: {
99
+ placements: {
100
+ outlets: [
101
+ { host: "workspace-tools", position: "primary-menu", source: "src/client/components/UsersWorkspaceToolsWidget.vue" }
102
+ ]
103
+ }
104
+ }
105
+ }
106
+ };
107
+ `
108
+ );
109
+
110
+ const discovered = await discoverShellOutletTargetsFromApp({ appRoot });
111
+ assert.deepEqual(
112
+ discovered.targets.map((entry) => entry.id),
113
+ ["shell-layout:primary-menu", "workspace-tools:primary-menu"]
114
+ );
115
+ assert.deepEqual(discovered.targets[1], {
116
+ id: "workspace-tools:primary-menu",
117
+ host: "workspace-tools",
118
+ position: "primary-menu",
119
+ default: false,
120
+ sourcePath: "package:@example/users-web:src/client/components/UsersWorkspaceToolsWidget.vue",
121
+ sourcePackageId: "@example/users-web"
122
+ });
123
+
124
+ const target = await resolveShellOutletPlacementTargetFromApp({
125
+ appRoot,
126
+ placement: "workspace-tools:primary-menu",
127
+ context: "ui-generator"
128
+ });
129
+ assert.equal(target.id, "workspace-tools:primary-menu");
130
+ });
131
+ });
132
+
133
+ test("discoverShellOutletTargetsFromApp returns targets with sourcePath and default marker", async () => {
134
+ await withTempApp(async (appRoot) => {
135
+ await writeFileInApp(
136
+ appRoot,
137
+ "src/components/ShellLayout.vue",
138
+ `<template>
139
+ <div>
140
+ <ShellOutlet host="shell-layout" position="primary-menu" />
141
+ </div>
142
+ </template>
143
+ `
144
+ );
145
+ await writeFileInApp(
146
+ appRoot,
147
+ "src/pages/admin/workspace/settings/index.vue",
148
+ `<template>
149
+ <section>
150
+ <ShellOutlet host="workspace-settings" position="forms" default />
151
+ </section>
152
+ </template>
153
+ `
154
+ );
155
+
156
+ const discovered = await discoverShellOutletTargetsFromApp({ appRoot });
157
+ assert.equal(discovered.defaultTargetId, "workspace-settings:forms");
158
+ assert.deepEqual(discovered.targets, [
159
+ {
160
+ id: "shell-layout:primary-menu",
161
+ host: "shell-layout",
162
+ position: "primary-menu",
163
+ default: false,
164
+ sourcePath: "src/components/ShellLayout.vue"
165
+ },
166
+ {
167
+ id: "workspace-settings:forms",
168
+ host: "workspace-settings",
169
+ position: "forms",
170
+ default: true,
171
+ sourcePath: "src/pages/admin/workspace/settings/index.vue"
172
+ }
173
+ ]);
174
+ });
175
+ });
176
+
177
+ test("resolveShellOutletPlacementTargetFromApp supports explicit placement override", async () => {
178
+ await withTempApp(async (appRoot) => {
179
+ await writeFileInApp(
180
+ appRoot,
181
+ "src/components/ShellLayout.vue",
182
+ `<template>
183
+ <div>
184
+ <ShellOutlet host="shell-layout" position="primary-menu" default />
185
+ <ShellOutlet host="shell-layout" position="top-right" />
186
+ </div>
187
+ </template>
188
+ `
189
+ );
190
+
191
+ const target = await resolveShellOutletPlacementTargetFromApp({
192
+ appRoot,
193
+ context: "ui-generator",
194
+ placement: "shell-layout:top-right"
195
+ });
196
+
197
+ assert.equal(target.host, "shell-layout");
198
+ assert.equal(target.position, "top-right");
199
+ });
200
+ });
201
+
202
+ test("resolveShellOutletPlacementTargetFromApp validates placement format", async () => {
203
+ await withTempApp(async (appRoot) => {
204
+ await writeFileInApp(
205
+ appRoot,
206
+ "src/components/ShellLayout.vue",
207
+ `<template>
208
+ <div>
209
+ <ShellOutlet host="shell-layout" position="primary-menu" default />
210
+ </div>
211
+ </template>
212
+ `
213
+ );
214
+
215
+ await assert.rejects(
216
+ () =>
217
+ resolveShellOutletPlacementTargetFromApp({
218
+ appRoot,
219
+ context: "ui-generator",
220
+ placement: "invalid-placement"
221
+ }),
222
+ /option "placement" must be in "host:position" format/
223
+ );
224
+ });
225
+ });
226
+
227
+ test("resolveShellOutletPlacementTargetFromApp throws when multiple default outlets exist", async () => {
228
+ await withTempApp(async (appRoot) => {
229
+ await writeFileInApp(
230
+ appRoot,
231
+ "src/components/ShellLayout.vue",
232
+ `<template>
233
+ <div>
234
+ <ShellOutlet host="shell-layout" position="primary-menu" default />
235
+ </div>
236
+ </template>
237
+ `
238
+ );
239
+ await writeFileInApp(
240
+ appRoot,
241
+ "src/pages/admin/workspace/settings/index.vue",
242
+ `<template>
243
+ <section>
244
+ <ShellOutlet host="workspace-settings" position="forms" default />
245
+ </section>
246
+ </template>
247
+ `
248
+ );
249
+
250
+ await assert.rejects(
251
+ () =>
252
+ resolveShellOutletPlacementTargetFromApp({
253
+ appRoot,
254
+ context: "ui-generator"
255
+ }),
256
+ /Multiple default ShellOutlet targets found in app source/
257
+ );
258
+ });
259
+ });
260
+
261
+ test("resolveShellOutletPlacementTargetFromApp requires appRoot", async () => {
262
+ await assert.rejects(
263
+ () =>
264
+ resolveShellOutletPlacementTargetFromApp({
265
+ appRoot: "",
266
+ context: "ui-generator"
267
+ }),
268
+ /requires appRoot/
269
+ );
270
+ });
@@ -0,0 +1,114 @@
1
+ import { normalizeText } from "./normalize.js";
2
+ import { normalizePathname } from "../surface/paths.js";
3
+
4
+ const DEFAULT_CRUD_LOOKUP_CONTAINER_KEY = "lookups";
5
+
6
+ function normalizeCrudLookupApiPath(value = "") {
7
+ const normalized = normalizePathname(normalizeText(value));
8
+ if (!normalized || normalized === "/") {
9
+ return "";
10
+ }
11
+
12
+ return normalized;
13
+ }
14
+
15
+ function normalizeCrudLookupNamespace(value = "") {
16
+ const normalizedApiPath = normalizeCrudLookupApiPath(value);
17
+ if (!normalizedApiPath) {
18
+ return "";
19
+ }
20
+
21
+ return normalizedApiPath.slice(1);
22
+ }
23
+
24
+ function resolveCrudLookupApiPathFromNamespace(value = "") {
25
+ const normalizedNamespace = normalizeCrudLookupNamespace(value);
26
+ if (!normalizedNamespace) {
27
+ return "";
28
+ }
29
+
30
+ return `/${normalizedNamespace}`;
31
+ }
32
+
33
+ function normalizeCrudLookupContainerKey(
34
+ value,
35
+ {
36
+ defaultValue = DEFAULT_CRUD_LOOKUP_CONTAINER_KEY,
37
+ context = "crud lookup container key"
38
+ } = {}
39
+ ) {
40
+ if (value === undefined || value === null || value === "") {
41
+ return normalizeText(defaultValue) || DEFAULT_CRUD_LOOKUP_CONTAINER_KEY;
42
+ }
43
+
44
+ const normalized = normalizeText(value);
45
+ if (!normalized) {
46
+ throw new TypeError(`${context} must be a non-empty string.`);
47
+ }
48
+
49
+ return normalized;
50
+ }
51
+
52
+ function resolveCrudLookupContainerKey(resource = {}, options = {}) {
53
+ const source = resource && typeof resource === "object" && !Array.isArray(resource) ? resource : {};
54
+ const contract = source.contract;
55
+ if (contract !== undefined && contract !== null && (typeof contract !== "object" || Array.isArray(contract))) {
56
+ throw new TypeError("crud resource contract must be an object when provided.");
57
+ }
58
+
59
+ const lookup = contract?.lookup;
60
+ if (lookup !== undefined && lookup !== null && (typeof lookup !== "object" || Array.isArray(lookup))) {
61
+ throw new TypeError("crud resource contract.lookup must be an object when provided.");
62
+ }
63
+
64
+ return normalizeCrudLookupContainerKey(lookup?.containerKey, options);
65
+ }
66
+
67
+ function resolveCrudLookupFieldKeys(resource = {}, { allowKeys = [] } = {}) {
68
+ const source = resource && typeof resource === "object" && !Array.isArray(resource) ? resource : {};
69
+ const entries = Array.isArray(source.fieldMeta) ? source.fieldMeta : [];
70
+ const allowedKeySet = new Set(
71
+ (Array.isArray(allowKeys) ? allowKeys : [])
72
+ .map((entry) => normalizeText(entry))
73
+ .filter(Boolean)
74
+ );
75
+
76
+ const keys = [];
77
+ const seenKeys = new Set();
78
+ for (const entry of entries) {
79
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
80
+ continue;
81
+ }
82
+
83
+ const key = normalizeText(entry.key);
84
+ if (!key || seenKeys.has(key)) {
85
+ continue;
86
+ }
87
+ if (allowedKeySet.size > 0 && !allowedKeySet.has(key)) {
88
+ continue;
89
+ }
90
+
91
+ const relation = entry.relation;
92
+ if (!relation || typeof relation !== "object" || Array.isArray(relation)) {
93
+ continue;
94
+ }
95
+ if (normalizeText(relation.kind).toLowerCase() !== "lookup") {
96
+ continue;
97
+ }
98
+
99
+ seenKeys.add(key);
100
+ keys.push(key);
101
+ }
102
+
103
+ return Object.freeze(keys);
104
+ }
105
+
106
+ export {
107
+ DEFAULT_CRUD_LOOKUP_CONTAINER_KEY,
108
+ normalizeCrudLookupApiPath,
109
+ normalizeCrudLookupNamespace,
110
+ resolveCrudLookupApiPathFromNamespace,
111
+ normalizeCrudLookupContainerKey,
112
+ resolveCrudLookupContainerKey,
113
+ resolveCrudLookupFieldKeys
114
+ };
@@ -0,0 +1,83 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ DEFAULT_CRUD_LOOKUP_CONTAINER_KEY,
5
+ normalizeCrudLookupApiPath,
6
+ normalizeCrudLookupNamespace,
7
+ resolveCrudLookupApiPathFromNamespace,
8
+ normalizeCrudLookupContainerKey,
9
+ resolveCrudLookupContainerKey,
10
+ resolveCrudLookupFieldKeys
11
+ } from "./crudLookup.js";
12
+
13
+ test("normalizeCrudLookupApiPath normalizes and rejects root", () => {
14
+ assert.equal(normalizeCrudLookupApiPath("vets"), "/vets");
15
+ assert.equal(normalizeCrudLookupApiPath("/vets//"), "/vets");
16
+ assert.equal(normalizeCrudLookupApiPath("/"), "");
17
+ });
18
+
19
+ test("normalizeCrudLookupNamespace normalizes namespace-like values", () => {
20
+ assert.equal(normalizeCrudLookupNamespace("vets"), "vets");
21
+ assert.equal(normalizeCrudLookupNamespace("/customer-categories//"), "customer-categories");
22
+ assert.equal(normalizeCrudLookupNamespace("/"), "");
23
+ });
24
+
25
+ test("resolveCrudLookupApiPathFromNamespace maps namespace to api path", () => {
26
+ assert.equal(resolveCrudLookupApiPathFromNamespace("vets"), "/vets");
27
+ assert.equal(resolveCrudLookupApiPathFromNamespace("/customer-categories"), "/customer-categories");
28
+ assert.equal(resolveCrudLookupApiPathFromNamespace(""), "");
29
+ });
30
+
31
+ test("normalizeCrudLookupContainerKey defaults to canonical value", () => {
32
+ assert.equal(normalizeCrudLookupContainerKey(undefined), DEFAULT_CRUD_LOOKUP_CONTAINER_KEY);
33
+ assert.equal(normalizeCrudLookupContainerKey(""), DEFAULT_CRUD_LOOKUP_CONTAINER_KEY);
34
+ });
35
+
36
+ test("resolveCrudLookupContainerKey reads resource contract override", () => {
37
+ assert.equal(
38
+ resolveCrudLookupContainerKey({
39
+ contract: {
40
+ lookup: {
41
+ containerKey: "lookupData"
42
+ }
43
+ }
44
+ }),
45
+ "lookupData"
46
+ );
47
+ });
48
+
49
+ test("resolveCrudLookupContainerKey throws for invalid contract shape", () => {
50
+ assert.throws(
51
+ () => resolveCrudLookupContainerKey({ contract: { lookup: [] } }),
52
+ /contract\.lookup must be an object/
53
+ );
54
+ });
55
+
56
+ test("resolveCrudLookupFieldKeys returns lookup field keys with optional allow-list", () => {
57
+ const resource = {
58
+ fieldMeta: [
59
+ {
60
+ key: "contactId",
61
+ relation: {
62
+ kind: "lookup",
63
+ apiPath: "/contacts",
64
+ valueKey: "id"
65
+ }
66
+ },
67
+ {
68
+ key: "status"
69
+ },
70
+ {
71
+ key: "vetId",
72
+ relation: {
73
+ kind: "lookup",
74
+ apiPath: "/vets",
75
+ valueKey: "id"
76
+ }
77
+ }
78
+ ]
79
+ };
80
+
81
+ assert.deepEqual(resolveCrudLookupFieldKeys(resource), ["contactId", "vetId"]);
82
+ assert.deepEqual(resolveCrudLookupFieldKeys(resource, { allowKeys: ["vetId", "missing"] }), ["vetId"]);
83
+ });
@@ -0,0 +1,127 @@
1
+ import { normalizeText } from "./normalize.js";
2
+
3
+ const SHELL_OUTLET_TAG_PATTERN = /<ShellOutlet\b([^>]*)\/?>/g;
4
+ const ATTRIBUTE_PATTERN = /([:@]?[A-Za-z_][A-Za-z0-9_-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g;
5
+
6
+ function parseTagAttributes(attributesSource = "") {
7
+ const attributes = {};
8
+ const source = String(attributesSource || "");
9
+ for (const match of source.matchAll(ATTRIBUTE_PATTERN)) {
10
+ const attributeName = normalizeText(match[1]);
11
+ if (!attributeName) {
12
+ continue;
13
+ }
14
+
15
+ const hasValue = match[2] != null || match[3] != null;
16
+ const attributeValue = hasValue ? String(match[2] ?? match[3] ?? "") : true;
17
+ attributes[attributeName] = attributeValue;
18
+ }
19
+
20
+ return attributes;
21
+ }
22
+
23
+ function normalizeShellOutletTargetId(value = "") {
24
+ const normalizedValue = normalizeText(value);
25
+ if (!normalizedValue) {
26
+ return "";
27
+ }
28
+
29
+ const separatorIndex = normalizedValue.indexOf(":");
30
+ if (separatorIndex <= 0 || separatorIndex >= normalizedValue.length - 1) {
31
+ return "";
32
+ }
33
+
34
+ const host = normalizeText(normalizedValue.slice(0, separatorIndex));
35
+ const position = normalizeText(normalizedValue.slice(separatorIndex + 1));
36
+ if (!host || !position) {
37
+ return "";
38
+ }
39
+
40
+ return `${host}:${position}`;
41
+ }
42
+
43
+ function findShellOutletTargetById(targets = [], targetId = "") {
44
+ const entries = Array.isArray(targets) ? targets : [];
45
+ const normalizedTargetId = normalizeShellOutletTargetId(targetId);
46
+ if (!normalizedTargetId) {
47
+ return null;
48
+ }
49
+
50
+ return entries.find((entry) => normalizeShellOutletTargetId(entry?.id) === normalizedTargetId) || null;
51
+ }
52
+
53
+ function describeShellOutletTargets(targets = []) {
54
+ return (Array.isArray(targets) ? targets : [])
55
+ .map((entry) => normalizeShellOutletTargetId(entry?.id))
56
+ .filter(Boolean)
57
+ .join(", ");
58
+ }
59
+
60
+ function isDefaultAttributeEnabled(value) {
61
+ if (value === true) {
62
+ return true;
63
+ }
64
+
65
+ const normalized = normalizeText(value).toLowerCase();
66
+ if (!normalized) {
67
+ return true;
68
+ }
69
+
70
+ return normalized !== "false" && normalized !== "0" && normalized !== "no" && normalized !== "off";
71
+ }
72
+
73
+ function discoverShellOutletTargetsFromVueSource(source = "", { context = "shell layout" } = {}) {
74
+ const sourceText = String(source || "");
75
+ const resolvedContext = normalizeText(context) || "shell layout";
76
+ const targetById = new Map();
77
+ let defaultTargetId = "";
78
+
79
+ for (const tagMatch of sourceText.matchAll(SHELL_OUTLET_TAG_PATTERN)) {
80
+ const attributes = parseTagAttributes(tagMatch[1]);
81
+ const host = normalizeText(attributes.host);
82
+ const position = normalizeText(attributes.position);
83
+ if (!host || !position) {
84
+ continue;
85
+ }
86
+
87
+ const id = normalizeShellOutletTargetId(`${host}:${position}`);
88
+ if (!id) {
89
+ continue;
90
+ }
91
+ if (targetById.has(id)) {
92
+ throw new Error(`${resolvedContext} contains duplicate ShellOutlet target "${id}".`);
93
+ }
94
+
95
+ const hasDefaultAttribute = Object.hasOwn(attributes, "default") && isDefaultAttributeEnabled(attributes.default);
96
+ if (hasDefaultAttribute) {
97
+ if (defaultTargetId) {
98
+ throw new Error(
99
+ `${resolvedContext} defines multiple default ShellOutlet targets: "${defaultTargetId}" and "${id}".`
100
+ );
101
+ }
102
+ defaultTargetId = id;
103
+ }
104
+
105
+ targetById.set(
106
+ id,
107
+ Object.freeze({
108
+ id,
109
+ host,
110
+ position,
111
+ default: hasDefaultAttribute
112
+ })
113
+ );
114
+ }
115
+
116
+ return Object.freeze({
117
+ targets: Object.freeze([...targetById.values()]),
118
+ defaultTargetId
119
+ });
120
+ }
121
+
122
+ export {
123
+ describeShellOutletTargets,
124
+ discoverShellOutletTargetsFromVueSource,
125
+ findShellOutletTargetById,
126
+ normalizeShellOutletTargetId
127
+ };
@@ -0,0 +1,84 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ describeShellOutletTargets,
5
+ discoverShellOutletTargetsFromVueSource,
6
+ findShellOutletTargetById,
7
+ normalizeShellOutletTargetId
8
+ } from "./shellLayoutTargets.js";
9
+
10
+ test("normalizeShellOutletTargetId validates host:position tokens", () => {
11
+ assert.equal(normalizeShellOutletTargetId("shell-layout:primary-menu"), "shell-layout:primary-menu");
12
+ assert.equal(normalizeShellOutletTargetId(" shell-layout : primary-menu "), "shell-layout:primary-menu");
13
+ assert.equal(normalizeShellOutletTargetId(""), "");
14
+ assert.equal(normalizeShellOutletTargetId("shell-layout"), "");
15
+ assert.equal(normalizeShellOutletTargetId("shell-layout:"), "");
16
+ });
17
+
18
+ test("discoverShellOutletTargetsFromVueSource resolves legal targets and one default", () => {
19
+ const source = `
20
+ <template>
21
+ <ShellOutlet host="shell-layout" position="top-left" />
22
+ <ShellOutlet host="shell-layout" position="primary-menu" default />
23
+ <ShellOutlet host="shell-layout" position="secondary-menu" />
24
+ </template>
25
+ `;
26
+
27
+ const discovered = discoverShellOutletTargetsFromVueSource(source, {
28
+ context: "ShellLayout.vue"
29
+ });
30
+
31
+ assert.equal(discovered.defaultTargetId, "shell-layout:primary-menu");
32
+ assert.deepEqual(
33
+ discovered.targets.map((entry) => entry.id),
34
+ ["shell-layout:top-left", "shell-layout:primary-menu", "shell-layout:secondary-menu"]
35
+ );
36
+ assert.equal(
37
+ describeShellOutletTargets(discovered.targets),
38
+ "shell-layout:top-left, shell-layout:primary-menu, shell-layout:secondary-menu"
39
+ );
40
+ assert.deepEqual(
41
+ findShellOutletTargetById(discovered.targets, " shell-layout : primary-menu "),
42
+ discovered.targets[1]
43
+ );
44
+ });
45
+
46
+ test("discoverShellOutletTargetsFromVueSource throws for duplicate targets", () => {
47
+ const source = `
48
+ <template>
49
+ <ShellOutlet host="shell-layout" position="top-right" />
50
+ <ShellOutlet host="shell-layout" position="top-right" />
51
+ </template>
52
+ `;
53
+
54
+ assert.throws(
55
+ () => discoverShellOutletTargetsFromVueSource(source, { context: "ShellLayout.vue" }),
56
+ /duplicate ShellOutlet target/
57
+ );
58
+ });
59
+
60
+ test("discoverShellOutletTargetsFromVueSource throws for multiple defaults", () => {
61
+ const source = `
62
+ <template>
63
+ <ShellOutlet host="shell-layout" position="primary-menu" default />
64
+ <ShellOutlet host="shell-layout" position="secondary-menu" default />
65
+ </template>
66
+ `;
67
+
68
+ assert.throws(
69
+ () => discoverShellOutletTargetsFromVueSource(source, { context: "ShellLayout.vue" }),
70
+ /multiple default ShellOutlet targets/
71
+ );
72
+ });
73
+
74
+ test("discoverShellOutletTargetsFromVueSource ignores disabled default markers", () => {
75
+ const source = `
76
+ <template>
77
+ <ShellOutlet host="shell-layout" position="primary-menu" default="false" />
78
+ <ShellOutlet host="shell-layout" position="secondary-menu" />
79
+ </template>
80
+ `;
81
+
82
+ const discovered = discoverShellOutletTargetsFromVueSource(source, { context: "ShellLayout.vue" });
83
+ assert.equal(discovered.defaultTargetId, "");
84
+ });
@@ -28,4 +28,6 @@ const cursorPaginationQueryValidator = Object.freeze({
28
28
  normalize: normalizeCursorPaginationQuery
29
29
  });
30
30
 
31
- export { cursorPaginationQueryValidator };
31
+ export {
32
+ cursorPaginationQueryValidator
33
+ };
@@ -19,3 +19,7 @@ test("cursorPaginationQueryValidator normalizes invalid values to 0", () => {
19
19
  test("cursorPaginationQueryValidator keeps absent keys absent", () => {
20
20
  assert.deepEqual(cursorPaginationQueryValidator.normalize({}), {});
21
21
  });
22
+
23
+ test("cursorPaginationQueryValidator ignores unsupported query fields", () => {
24
+ assert.deepEqual(cursorPaginationQueryValidator.normalize({ q: " to " }), {});
25
+ });