@jskit-ai/kernel 0.1.21 → 0.1.23
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 +1 -1
- package/server/http/lib/kernel.test.js +2 -2
- package/server/support/shellOutlets.js +150 -18
- package/server/support/shellOutlets.test.js +46 -0
- package/shared/actions/executionContext.test.js +4 -4
- package/shared/support/normalize.js +38 -1
- package/shared/support/normalize.test.js +37 -0
package/package.json
CHANGED
|
@@ -134,7 +134,7 @@ test("registerRoutes attaches request.executeAction and applies action context c
|
|
|
134
134
|
slug: "main"
|
|
135
135
|
};
|
|
136
136
|
request.membership = {
|
|
137
|
-
|
|
137
|
+
roleSid: "owner"
|
|
138
138
|
};
|
|
139
139
|
}
|
|
140
140
|
],
|
|
@@ -191,7 +191,7 @@ test("registerRoutes attaches request.executeAction and applies action context c
|
|
|
191
191
|
assert.equal(observed[0].context.actor?.id, 7);
|
|
192
192
|
assert.deepEqual(observed[0].context.permissions, ["settings.read"]);
|
|
193
193
|
assert.equal(observed[0].context.workspace?.id, 10);
|
|
194
|
-
assert.equal(observed[0].context.membership?.
|
|
194
|
+
assert.equal(observed[0].context.membership?.roleSid, "owner");
|
|
195
195
|
assert.equal(observed[0].context.surface, "coffie");
|
|
196
196
|
assert.equal(observed[0].context.channel, "api");
|
|
197
197
|
assert.equal(observed[0].context.requestMeta.commandId, "cmd-1");
|
|
@@ -12,6 +12,125 @@ import {
|
|
|
12
12
|
|
|
13
13
|
const VUE_DISCOVERY_IGNORED_ERROR_CODES = new Set(["ENOENT", "ENOTDIR", "EACCES", "EPERM"]);
|
|
14
14
|
const LOCK_FILE_RELATIVE_PATH = ".jskit/lock.json";
|
|
15
|
+
const ROUTE_TAG_PATTERN = /<route\b([^>]*)>([\s\S]*?)<\/route>/g;
|
|
16
|
+
const ATTRIBUTE_PATTERN = /([:@]?[A-Za-z_][A-Za-z0-9_-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g;
|
|
17
|
+
|
|
18
|
+
function parseTagAttributes(attributesSource = "") {
|
|
19
|
+
const attributes = {};
|
|
20
|
+
const source = String(attributesSource || "");
|
|
21
|
+
for (const match of source.matchAll(ATTRIBUTE_PATTERN)) {
|
|
22
|
+
const attributeName = normalizeText(match[1]);
|
|
23
|
+
if (!attributeName) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const hasValue = match[2] != null || match[3] != null;
|
|
28
|
+
const attributeValue = hasValue ? String(match[2] ?? match[3] ?? "") : true;
|
|
29
|
+
attributes[attributeName] = attributeValue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return attributes;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isDefaultEnabled(value) {
|
|
36
|
+
if (value === true) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
if (value === false || value == null) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const normalized = normalizeText(value).toLowerCase();
|
|
44
|
+
if (!normalized) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeAppRouteOutletTarget({
|
|
52
|
+
outlet = {},
|
|
53
|
+
sourcePath = ""
|
|
54
|
+
} = {}) {
|
|
55
|
+
const outletRecord = normalizeObject(outlet);
|
|
56
|
+
const outletTargetId = normalizeShellOutletTargetId(
|
|
57
|
+
`${normalizeText(outletRecord.host)}:${normalizeText(outletRecord.position)}`
|
|
58
|
+
);
|
|
59
|
+
if (!outletTargetId) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const separatorIndex = outletTargetId.indexOf(":");
|
|
64
|
+
const host = outletTargetId.slice(0, separatorIndex);
|
|
65
|
+
const position = outletTargetId.slice(separatorIndex + 1);
|
|
66
|
+
return Object.freeze({
|
|
67
|
+
id: outletTargetId,
|
|
68
|
+
host,
|
|
69
|
+
position,
|
|
70
|
+
default: isDefaultEnabled(outletRecord.default),
|
|
71
|
+
sourcePath
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function discoverRouteMetaOutletTargetsFromVueSource(source = "", { context = "shell layout" } = {}) {
|
|
76
|
+
const sourceText = String(source || "");
|
|
77
|
+
const resolvedContext = normalizeText(context) || "shell layout";
|
|
78
|
+
const targetById = new Map();
|
|
79
|
+
let defaultTargetId = "";
|
|
80
|
+
|
|
81
|
+
for (const routeTagMatch of sourceText.matchAll(ROUTE_TAG_PATTERN)) {
|
|
82
|
+
const routeTagAttributes = parseTagAttributes(routeTagMatch[1]);
|
|
83
|
+
const routeTagLanguage = normalizeText(routeTagAttributes.lang).toLowerCase();
|
|
84
|
+
if (routeTagLanguage !== "json") {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const routeMetaSource = String(routeTagMatch[2] || "").trim();
|
|
89
|
+
if (!routeMetaSource) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let routeMetaRecord = null;
|
|
94
|
+
try {
|
|
95
|
+
routeMetaRecord = JSON.parse(routeMetaSource);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`${resolvedContext} contains invalid <route lang="json"> block: ${String(error?.message || error || "unknown error")}`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const routeMeta = normalizeObject(normalizeObject(routeMetaRecord).meta);
|
|
103
|
+
const jskitMeta = normalizeObject(routeMeta.jskit);
|
|
104
|
+
const placementsMeta = normalizeObject(jskitMeta.placements);
|
|
105
|
+
const outlets = Array.isArray(placementsMeta.outlets) ? placementsMeta.outlets : [];
|
|
106
|
+
for (const outlet of outlets) {
|
|
107
|
+
const normalizedTarget = normalizeAppRouteOutletTarget({
|
|
108
|
+
outlet,
|
|
109
|
+
sourcePath: resolvedContext
|
|
110
|
+
});
|
|
111
|
+
if (!normalizedTarget) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (targetById.has(normalizedTarget.id)) {
|
|
115
|
+
throw new Error(`${resolvedContext} contains duplicate route meta placement target "${normalizedTarget.id}".`);
|
|
116
|
+
}
|
|
117
|
+
if (normalizedTarget.default === true) {
|
|
118
|
+
if (defaultTargetId && defaultTargetId !== normalizedTarget.id) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`${resolvedContext} defines multiple default route meta placement targets: "${defaultTargetId}" and "${normalizedTarget.id}".`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
defaultTargetId = normalizedTarget.id;
|
|
124
|
+
}
|
|
125
|
+
targetById.set(normalizedTarget.id, normalizedTarget);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return Object.freeze({
|
|
130
|
+
targets: Object.freeze([...targetById.values()]),
|
|
131
|
+
defaultTargetId
|
|
132
|
+
});
|
|
133
|
+
}
|
|
15
134
|
|
|
16
135
|
async function collectVueFilePaths(rootDirectoryPath) {
|
|
17
136
|
const absoluteRoot = path.resolve(String(rootDirectoryPath || ""));
|
|
@@ -154,15 +273,27 @@ async function discoverShellOutletTargetsFromApp({ appRoot, sourceRoot = "src" }
|
|
|
154
273
|
for (const absoluteFilePath of vueFiles) {
|
|
155
274
|
const relativePath = toPosixPath(path.relative(resolvedAppRoot, absoluteFilePath));
|
|
156
275
|
const source = await readFile(absoluteFilePath, "utf8");
|
|
157
|
-
if (!source.includes("<ShellOutlet")) {
|
|
276
|
+
if (!source.includes("<ShellOutlet") && !source.includes("<route")) {
|
|
158
277
|
continue;
|
|
159
278
|
}
|
|
160
279
|
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
280
|
+
const discoveredShellOutlets = source.includes("<ShellOutlet")
|
|
281
|
+
? discoverShellOutletTargetsFromVueSource(source, {
|
|
282
|
+
context: relativePath
|
|
283
|
+
})
|
|
284
|
+
: { targets: [], defaultTargetId: "" };
|
|
285
|
+
const discoveredRouteMetaOutlets = source.includes("<route")
|
|
286
|
+
? discoverRouteMetaOutletTargetsFromVueSource(source, {
|
|
287
|
+
context: relativePath
|
|
288
|
+
})
|
|
289
|
+
: { targets: [], defaultTargetId: "" };
|
|
290
|
+
const discoveredTargets = [
|
|
291
|
+
...(Array.isArray(discoveredShellOutlets.targets) ? discoveredShellOutlets.targets : []),
|
|
292
|
+
...(Array.isArray(discoveredRouteMetaOutlets.targets)
|
|
293
|
+
? discoveredRouteMetaOutlets.targets
|
|
294
|
+
: [])
|
|
295
|
+
];
|
|
296
|
+
for (const target of discoveredTargets) {
|
|
166
297
|
if (!targetById.has(target.id)) {
|
|
167
298
|
targetById.set(
|
|
168
299
|
target.id,
|
|
@@ -174,20 +305,21 @@ async function discoverShellOutletTargetsFromApp({ appRoot, sourceRoot = "src" }
|
|
|
174
305
|
}
|
|
175
306
|
}
|
|
176
307
|
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
308
|
+
const discoveredDefaultTargetIds = [
|
|
309
|
+
normalizeShellOutletTargetId(discoveredShellOutlets.defaultTargetId),
|
|
310
|
+
normalizeShellOutletTargetId(discoveredRouteMetaOutlets.defaultTargetId)
|
|
311
|
+
].filter(Boolean);
|
|
312
|
+
for (const discoveredDefaultTargetId of discoveredDefaultTargetIds) {
|
|
313
|
+
if (defaultTargetId && discoveredDefaultTargetId !== defaultTargetId) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`Multiple default ShellOutlet targets found in app source: "${defaultTargetId}" (${defaultTargetSource}) and ` +
|
|
316
|
+
`"${discoveredDefaultTargetId}" (${relativePath}).`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
181
319
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
`Multiple default ShellOutlet targets found in app source: "${defaultTargetId}" (${defaultTargetSource}) and ` +
|
|
185
|
-
`"${discoveredDefaultTargetId}" (${relativePath}).`
|
|
186
|
-
);
|
|
320
|
+
defaultTargetId = discoveredDefaultTargetId;
|
|
321
|
+
defaultTargetSource = relativePath;
|
|
187
322
|
}
|
|
188
|
-
|
|
189
|
-
defaultTargetId = discoveredDefaultTargetId;
|
|
190
|
-
defaultTargetSource = relativePath;
|
|
191
323
|
}
|
|
192
324
|
|
|
193
325
|
const packageTargets = await collectInstalledPackageOutletTargets(resolvedAppRoot);
|
|
@@ -174,6 +174,52 @@ test("discoverShellOutletTargetsFromApp returns targets with sourcePath and defa
|
|
|
174
174
|
});
|
|
175
175
|
});
|
|
176
176
|
|
|
177
|
+
test("discoverShellOutletTargetsFromApp discovers route meta placement outlets", async () => {
|
|
178
|
+
await withTempApp(async (appRoot) => {
|
|
179
|
+
await writeFileInApp(
|
|
180
|
+
appRoot,
|
|
181
|
+
"src/pages/w/[workspaceSlug]/admin/contacts/[contactId]/contact-tools.vue",
|
|
182
|
+
`<template><section /></template>
|
|
183
|
+
|
|
184
|
+
<route lang="json">
|
|
185
|
+
{
|
|
186
|
+
"meta": {
|
|
187
|
+
"jskit": {
|
|
188
|
+
"placements": {
|
|
189
|
+
"outlets": [
|
|
190
|
+
{
|
|
191
|
+
"host": "contact-tools",
|
|
192
|
+
"position": "sub-pages"
|
|
193
|
+
}
|
|
194
|
+
]
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
</route>
|
|
200
|
+
`
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const discovered = await discoverShellOutletTargetsFromApp({ appRoot });
|
|
204
|
+
assert.deepEqual(discovered.targets, [
|
|
205
|
+
{
|
|
206
|
+
id: "contact-tools:sub-pages",
|
|
207
|
+
host: "contact-tools",
|
|
208
|
+
position: "sub-pages",
|
|
209
|
+
default: false,
|
|
210
|
+
sourcePath: "src/pages/w/[workspaceSlug]/admin/contacts/[contactId]/contact-tools.vue"
|
|
211
|
+
}
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
const target = await resolveShellOutletPlacementTargetFromApp({
|
|
215
|
+
appRoot,
|
|
216
|
+
placement: "contact-tools:sub-pages",
|
|
217
|
+
context: "ui-generator"
|
|
218
|
+
});
|
|
219
|
+
assert.equal(target.id, "contact-tools:sub-pages");
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
177
223
|
test("resolveShellOutletPlacementTargetFromApp supports explicit placement override", async () => {
|
|
178
224
|
await withTempApp(async (appRoot) => {
|
|
179
225
|
await writeFileInApp(
|
|
@@ -42,11 +42,11 @@ test("normalizeExecutionContext keeps actor payload generic", () => {
|
|
|
42
42
|
actor: {
|
|
43
43
|
id: "user_1",
|
|
44
44
|
email: "UPPER@EXAMPLE.COM",
|
|
45
|
-
|
|
45
|
+
roleSid: "OWNER",
|
|
46
46
|
customFlag: true
|
|
47
47
|
},
|
|
48
48
|
membership: {
|
|
49
|
-
|
|
49
|
+
roleSid: "OWNER",
|
|
50
50
|
status: "ACTIVE",
|
|
51
51
|
extra: "x"
|
|
52
52
|
}
|
|
@@ -55,11 +55,11 @@ test("normalizeExecutionContext keeps actor payload generic", () => {
|
|
|
55
55
|
assert.deepEqual(context.actor, {
|
|
56
56
|
id: "user_1",
|
|
57
57
|
email: "UPPER@EXAMPLE.COM",
|
|
58
|
-
|
|
58
|
+
roleSid: "OWNER",
|
|
59
59
|
customFlag: true
|
|
60
60
|
});
|
|
61
61
|
assert.deepEqual(context.membership, {
|
|
62
|
-
|
|
62
|
+
roleSid: "OWNER",
|
|
63
63
|
status: "ACTIVE",
|
|
64
64
|
extra: "x"
|
|
65
65
|
});
|
|
@@ -3,6 +3,18 @@ function normalizeText(value, { fallback = "" } = {}) {
|
|
|
3
3
|
return normalized || fallback;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
function hasValue(value) {
|
|
7
|
+
if (value == null) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (typeof value === "string") {
|
|
12
|
+
return normalizeText(value).length > 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
function normalizeBoolean(value) {
|
|
7
19
|
if (typeof value === "boolean") {
|
|
8
20
|
return value;
|
|
@@ -88,7 +100,31 @@ function normalizeIfInSource(source, normalized, fieldName, normalizer = (value)
|
|
|
88
100
|
return normalized;
|
|
89
101
|
}
|
|
90
102
|
|
|
91
|
-
|
|
103
|
+
try {
|
|
104
|
+
normalized[normalizedFieldName] = normalizer(sourceValue);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
const normalizedMessage = normalizeText(error?.message, {
|
|
107
|
+
fallback: `${normalizedFieldName} is invalid.`
|
|
108
|
+
});
|
|
109
|
+
const sourceDetails = isRecord(error?.details) ? error.details : {};
|
|
110
|
+
const sourceFieldErrors = isRecord(error?.fieldErrors)
|
|
111
|
+
? { ...error.fieldErrors }
|
|
112
|
+
: isRecord(sourceDetails.fieldErrors)
|
|
113
|
+
? { ...sourceDetails.fieldErrors }
|
|
114
|
+
: {};
|
|
115
|
+
|
|
116
|
+
if (!normalizeText(sourceFieldErrors[normalizedFieldName])) {
|
|
117
|
+
sourceFieldErrors[normalizedFieldName] = normalizedMessage;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const normalizedError = error instanceof Error ? error : new Error(normalizedMessage);
|
|
121
|
+
normalizedError.fieldErrors = sourceFieldErrors;
|
|
122
|
+
normalizedError.details = {
|
|
123
|
+
...sourceDetails,
|
|
124
|
+
fieldErrors: sourceFieldErrors
|
|
125
|
+
};
|
|
126
|
+
throw normalizedError;
|
|
127
|
+
}
|
|
92
128
|
return normalized;
|
|
93
129
|
}
|
|
94
130
|
|
|
@@ -193,6 +229,7 @@ function ensureNonEmptyText(value, label = "value") {
|
|
|
193
229
|
|
|
194
230
|
export {
|
|
195
231
|
normalizeText,
|
|
232
|
+
hasValue,
|
|
196
233
|
normalizeBoolean,
|
|
197
234
|
normalizeFiniteNumber,
|
|
198
235
|
normalizeFiniteInteger,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import {
|
|
4
|
+
hasValue,
|
|
4
5
|
normalizeBoolean,
|
|
5
6
|
normalizeFiniteInteger,
|
|
6
7
|
normalizeFiniteNumber,
|
|
@@ -15,6 +16,19 @@ import {
|
|
|
15
16
|
normalizeUniqueTextList
|
|
16
17
|
} from "./normalize.js";
|
|
17
18
|
|
|
19
|
+
test("hasValue returns false for nullish and blank text, true otherwise", () => {
|
|
20
|
+
assert.equal(hasValue(null), false);
|
|
21
|
+
assert.equal(hasValue(undefined), false);
|
|
22
|
+
assert.equal(hasValue(""), false);
|
|
23
|
+
assert.equal(hasValue(" "), false);
|
|
24
|
+
assert.equal(hasValue("0"), true);
|
|
25
|
+
assert.equal(hasValue("-"), true);
|
|
26
|
+
assert.equal(hasValue(0), true);
|
|
27
|
+
assert.equal(hasValue(false), true);
|
|
28
|
+
assert.equal(hasValue([]), true);
|
|
29
|
+
assert.equal(hasValue({}), true);
|
|
30
|
+
});
|
|
31
|
+
|
|
18
32
|
test("normalizeQueryToken trims, lowercases, and falls back when empty", () => {
|
|
19
33
|
assert.equal(normalizeQueryToken(" Admin "), "admin");
|
|
20
34
|
assert.equal(normalizeQueryToken(""), "__none__");
|
|
@@ -100,6 +114,29 @@ test("normalizeIfInSource passes through nullish values without normalizer", ()
|
|
|
100
114
|
});
|
|
101
115
|
});
|
|
102
116
|
|
|
117
|
+
test("normalizeIfInSource annotates thrown normalizer errors with fieldErrors", () => {
|
|
118
|
+
const source = {
|
|
119
|
+
temperament: "unknowne"
|
|
120
|
+
};
|
|
121
|
+
const normalized = {};
|
|
122
|
+
|
|
123
|
+
assert.throws(
|
|
124
|
+
() =>
|
|
125
|
+
normalizeIfInSource(source, normalized, "temperament", () => {
|
|
126
|
+
throw new Error("Invalid pet temperament \"unknowne\".");
|
|
127
|
+
}),
|
|
128
|
+
(error) => {
|
|
129
|
+
assert.deepEqual(error?.fieldErrors, {
|
|
130
|
+
temperament: "Invalid pet temperament \"unknowne\"."
|
|
131
|
+
});
|
|
132
|
+
assert.deepEqual(error?.details?.fieldErrors, {
|
|
133
|
+
temperament: "Invalid pet temperament \"unknowne\"."
|
|
134
|
+
});
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
103
140
|
test("normalizeIfInSource validates target and function arguments", () => {
|
|
104
141
|
assert.throws(
|
|
105
142
|
() => normalizeIfInSource({ firstName: "Ada" }, null, "firstName", normalizeText),
|