@jskit-ai/kernel 0.1.22 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/kernel",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "typebox": "^1.0.81"
@@ -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
- roleId: "owner"
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?.roleId, "owner");
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 discovered = discoverShellOutletTargetsFromVueSource(source, {
162
- context: relativePath
163
- });
164
- const targets = Array.isArray(discovered.targets) ? discovered.targets : [];
165
- for (const target of targets) {
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 discoveredDefaultTargetId = normalizeShellOutletTargetId(discovered.defaultTargetId);
178
- if (!discoveredDefaultTargetId) {
179
- continue;
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
- if (defaultTargetId && discoveredDefaultTargetId !== defaultTargetId) {
183
- throw new Error(
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
- roleId: "OWNER",
45
+ roleSid: "OWNER",
46
46
  customFlag: true
47
47
  },
48
48
  membership: {
49
- roleId: "OWNER",
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
- roleId: "OWNER",
58
+ roleSid: "OWNER",
59
59
  customFlag: true
60
60
  });
61
61
  assert.deepEqual(context.membership, {
62
- roleId: "OWNER",
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
- normalized[normalizedFieldName] = normalizer(sourceValue);
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),