@jskit-ai/kernel 0.1.32 → 0.1.34
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 +2 -1
- package/server/http/lib/kernel.test.js +5 -5
- package/server/runtime/entityChangeEvents.js +5 -5
- package/server/runtime/entityChangeEvents.test.js +5 -5
- package/server/runtime/serviceAuthorization.test.js +1 -1
- package/server/support/importFreshModuleFromAbsolutePath.js +23 -0
- package/server/support/importFreshModuleFromAbsolutePath.test.js +36 -0
- package/server/support/index.js +1 -0
- package/server/support/pageTargets.js +55 -35
- package/server/support/pageTargets.test.js +133 -31
- package/server/support/shellOutlets.js +62 -30
- package/server/support/shellOutlets.test.js +86 -27
- package/shared/support/normalize.js +31 -1
- package/shared/support/normalize.test.js +21 -2
- package/shared/support/shellLayoutTargets.js +68 -25
- package/shared/support/shellLayoutTargets.test.js +27 -9
- package/shared/support/visibility.js +1 -1
- package/shared/support/visibility.test.js +5 -5
- package/shared/validators/cursorPaginationQueryValidator.js +3 -4
- package/shared/validators/cursorPaginationQueryValidator.test.js +2 -3
- package/shared/validators/index.js +11 -1
- package/shared/validators/recordIdParamsValidator.js +47 -9
- package/shared/validators/recordIdParamsValidator.test.js +7 -4
- package/README.md +0 -24
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
3
4
|
import { loadInstalledPackageDescriptor } from "../../internal/node/installedPackageDescriptor.js";
|
|
4
5
|
import { normalizeObject, normalizeText } from "../../shared/support/normalize.js";
|
|
5
6
|
import { resolveRequiredAppRoot, toPosixPath } from "./path.js";
|
|
@@ -7,8 +8,10 @@ import {
|
|
|
7
8
|
describeShellOutletTargets,
|
|
8
9
|
discoverShellOutletTargetsFromVueSource,
|
|
9
10
|
findShellOutletTargetById,
|
|
10
|
-
normalizeShellOutletTargetId
|
|
11
|
+
normalizeShellOutletTargetId,
|
|
12
|
+
normalizeShellOutletTargetRecord
|
|
11
13
|
} from "../../shared/support/shellLayoutTargets.js";
|
|
14
|
+
import { loadAppConfigFromModuleUrl } from "./appConfigFiles.js";
|
|
12
15
|
|
|
13
16
|
const VUE_DISCOVERY_IGNORED_ERROR_CODES = new Set(["ENOENT", "ENOTDIR", "EACCES", "EPERM"]);
|
|
14
17
|
const LOCK_FILE_RELATIVE_PATH = ".jskit/lock.json";
|
|
@@ -52,22 +55,15 @@ function normalizeAppRouteOutletTarget({
|
|
|
52
55
|
outlet = {},
|
|
53
56
|
sourcePath = ""
|
|
54
57
|
} = {}) {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
)
|
|
59
|
-
if (!outletTargetId) {
|
|
58
|
+
const normalizedTarget = normalizeShellOutletTargetRecord(outlet, {
|
|
59
|
+
context: sourcePath || "route meta"
|
|
60
|
+
});
|
|
61
|
+
if (!normalizedTarget) {
|
|
60
62
|
return null;
|
|
61
63
|
}
|
|
62
|
-
|
|
63
|
-
const separatorIndex = outletTargetId.indexOf(":");
|
|
64
|
-
const host = outletTargetId.slice(0, separatorIndex);
|
|
65
|
-
const position = outletTargetId.slice(separatorIndex + 1);
|
|
66
64
|
return Object.freeze({
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
position,
|
|
70
|
-
default: isDefaultEnabled(outletRecord.default),
|
|
65
|
+
...normalizedTarget,
|
|
66
|
+
default: isDefaultEnabled(normalizedTarget.default),
|
|
71
67
|
sourcePath
|
|
72
68
|
});
|
|
73
69
|
}
|
|
@@ -201,32 +197,67 @@ function normalizePackageOutletTarget({
|
|
|
201
197
|
return null;
|
|
202
198
|
}
|
|
203
199
|
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
)
|
|
208
|
-
if (!outletTargetId) {
|
|
200
|
+
const normalizedTarget = normalizeShellOutletTargetRecord(outlet, {
|
|
201
|
+
context: `package:${normalizedPackageId}`
|
|
202
|
+
});
|
|
203
|
+
if (!normalizedTarget) {
|
|
209
204
|
return null;
|
|
210
205
|
}
|
|
211
206
|
|
|
212
|
-
const
|
|
213
|
-
const host = outletTargetId.slice(0, separatorIndex);
|
|
214
|
-
const position = outletTargetId.slice(separatorIndex + 1);
|
|
207
|
+
const outletRecord = normalizeObject(outlet);
|
|
215
208
|
const source = normalizeText(outletRecord.source);
|
|
216
209
|
const sourcePath = source
|
|
217
210
|
? `package:${normalizedPackageId}:${toPosixPath(source)}`
|
|
218
211
|
: `package:${normalizedPackageId}${descriptorPath ? `:${toPosixPath(descriptorPath)}` : ""}`;
|
|
219
212
|
|
|
220
213
|
return Object.freeze({
|
|
221
|
-
|
|
222
|
-
host,
|
|
223
|
-
position,
|
|
214
|
+
...normalizedTarget,
|
|
224
215
|
default: false,
|
|
225
216
|
sourcePath,
|
|
226
217
|
sourcePackageId: normalizedPackageId
|
|
227
218
|
});
|
|
228
219
|
}
|
|
229
220
|
|
|
221
|
+
async function loadOutletDefaultOverrides(appRoot = "") {
|
|
222
|
+
const resolvedAppRoot = resolveRequiredAppRoot(appRoot, {
|
|
223
|
+
context: "discoverShellOutletTargetsFromApp"
|
|
224
|
+
});
|
|
225
|
+
let appConfig = {};
|
|
226
|
+
try {
|
|
227
|
+
appConfig = normalizeObject(
|
|
228
|
+
await loadAppConfigFromModuleUrl({
|
|
229
|
+
moduleUrl: pathToFileURL(path.join(resolvedAppRoot, "config", "public.js")).href
|
|
230
|
+
})
|
|
231
|
+
);
|
|
232
|
+
} catch {
|
|
233
|
+
return {};
|
|
234
|
+
}
|
|
235
|
+
return normalizeObject(normalizeObject(appConfig.ui).outletDefaults);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function applyOutletDefaultOverrides(target = {}, outletDefaultOverrides = {}) {
|
|
239
|
+
const targetRecord = normalizeObject(target);
|
|
240
|
+
const outletTargetId = normalizeShellOutletTargetId(targetRecord.id);
|
|
241
|
+
if (!outletTargetId) {
|
|
242
|
+
return targetRecord;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const overrideRecord = outletDefaultOverrides?.[outletTargetId];
|
|
246
|
+
const normalizedOverrideToken =
|
|
247
|
+
typeof overrideRecord === "string"
|
|
248
|
+
? normalizeText(overrideRecord)
|
|
249
|
+
: normalizeText(normalizeObject(overrideRecord).linkComponentToken) ||
|
|
250
|
+
normalizeText(normalizeObject(overrideRecord)["link-component-token"]);
|
|
251
|
+
if (!normalizedOverrideToken) {
|
|
252
|
+
return targetRecord;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return Object.freeze({
|
|
256
|
+
...targetRecord,
|
|
257
|
+
defaultLinkComponentToken: normalizedOverrideToken
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
230
261
|
async function collectInstalledPackageOutletTargets(appRoot) {
|
|
231
262
|
const installedPackageStates = await readInstalledPackageStates(appRoot);
|
|
232
263
|
const packageIds = Object.keys(installedPackageStates).sort((left, right) => left.localeCompare(right));
|
|
@@ -263,6 +294,7 @@ async function discoverShellOutletTargetsFromApp({ appRoot, sourceRoot = "src" }
|
|
|
263
294
|
const resolvedAppRoot = resolveRequiredAppRoot(appRoot, {
|
|
264
295
|
context: "discoverShellOutletTargetsFromApp"
|
|
265
296
|
});
|
|
297
|
+
const outletDefaultOverrides = await loadOutletDefaultOverrides(resolvedAppRoot);
|
|
266
298
|
|
|
267
299
|
const sourceDirectory = path.resolve(resolvedAppRoot, String(sourceRoot || "src"));
|
|
268
300
|
const targetById = new Map();
|
|
@@ -331,10 +363,10 @@ async function discoverShellOutletTargetsFromApp({ appRoot, sourceRoot = "src" }
|
|
|
331
363
|
|
|
332
364
|
const targets = [...targetById.values()].sort((left, right) => left.id.localeCompare(right.id));
|
|
333
365
|
const normalizedTargets = targets.map((target) =>
|
|
334
|
-
|
|
366
|
+
applyOutletDefaultOverrides({
|
|
335
367
|
...target,
|
|
336
368
|
default: target.id === defaultTargetId
|
|
337
|
-
})
|
|
369
|
+
}, outletDefaultOverrides)
|
|
338
370
|
);
|
|
339
371
|
|
|
340
372
|
return Object.freeze({
|
|
@@ -348,7 +380,7 @@ async function resolveShellOutletPlacementTargetFromApp({ appRoot, placement = "
|
|
|
348
380
|
const requestedPlacementOption = normalizeText(placement);
|
|
349
381
|
const requestedPlacementTargetId = normalizeShellOutletTargetId(requestedPlacementOption);
|
|
350
382
|
if (requestedPlacementOption && !requestedPlacementTargetId) {
|
|
351
|
-
throw new Error(`${resolvedContext} option "placement" must be in "host:position" format.`);
|
|
383
|
+
throw new Error(`${resolvedContext} option "placement" must be a target in "host:position" format.`);
|
|
352
384
|
}
|
|
353
385
|
|
|
354
386
|
const discovered = await discoverShellOutletTargetsFromApp({ appRoot, sourceRoot: "src" });
|
|
@@ -380,8 +412,8 @@ async function resolveShellOutletPlacementTargetFromApp({ appRoot, placement = "
|
|
|
380
412
|
const availableTargets = describeShellOutletTargets(targets);
|
|
381
413
|
throw new Error(
|
|
382
414
|
`${resolvedContext} could not resolve a default ShellOutlet target from app Vue outlets. ` +
|
|
383
|
-
`Set one outlet as default (e.g. <ShellOutlet
|
|
384
|
-
`or pass "--placement
|
|
415
|
+
`Set one outlet as default (e.g. <ShellOutlet target="shell-layout:primary-menu" default />) ` +
|
|
416
|
+
`or pass "--placement shell-layout:primary-menu". Available targets: ${availableTargets || "<none>"}.`
|
|
385
417
|
);
|
|
386
418
|
}
|
|
387
419
|
|
|
@@ -30,8 +30,8 @@ test("resolveShellOutletPlacementTargetFromApp reads outlets across app Vue file
|
|
|
30
30
|
"src/components/ShellLayout.vue",
|
|
31
31
|
`<template>
|
|
32
32
|
<div>
|
|
33
|
-
<ShellOutlet
|
|
34
|
-
<ShellOutlet
|
|
33
|
+
<ShellOutlet target="shell-layout:primary-menu" />
|
|
34
|
+
<ShellOutlet target="shell-layout:top-right" />
|
|
35
35
|
</div>
|
|
36
36
|
</template>
|
|
37
37
|
`
|
|
@@ -41,7 +41,7 @@ test("resolveShellOutletPlacementTargetFromApp reads outlets across app Vue file
|
|
|
41
41
|
"src/pages/admin/workspace/settings/index.vue",
|
|
42
42
|
`<template>
|
|
43
43
|
<section>
|
|
44
|
-
<ShellOutlet
|
|
44
|
+
<ShellOutlet target="admin-settings:forms" default />
|
|
45
45
|
</section>
|
|
46
46
|
</template>
|
|
47
47
|
`
|
|
@@ -52,8 +52,7 @@ test("resolveShellOutletPlacementTargetFromApp reads outlets across app Vue file
|
|
|
52
52
|
context: "ui-generator"
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
assert.equal(target.
|
|
56
|
-
assert.equal(target.position, "forms");
|
|
55
|
+
assert.equal(target.id, "admin-settings:forms");
|
|
57
56
|
});
|
|
58
57
|
});
|
|
59
58
|
|
|
@@ -64,7 +63,7 @@ test("discoverShellOutletTargetsFromApp includes installed package placement out
|
|
|
64
63
|
"src/components/ShellLayout.vue",
|
|
65
64
|
`<template>
|
|
66
65
|
<div>
|
|
67
|
-
<ShellOutlet
|
|
66
|
+
<ShellOutlet target="shell-layout:primary-menu" default />
|
|
68
67
|
</div>
|
|
69
68
|
</template>
|
|
70
69
|
`
|
|
@@ -98,7 +97,11 @@ test("discoverShellOutletTargetsFromApp includes installed package placement out
|
|
|
98
97
|
ui: {
|
|
99
98
|
placements: {
|
|
100
99
|
outlets: [
|
|
101
|
-
{
|
|
100
|
+
{
|
|
101
|
+
target: "workspace-tools:primary-menu",
|
|
102
|
+
defaultLinkComponentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
103
|
+
source: "src/client/components/UsersWorkspaceToolsWidget.vue"
|
|
104
|
+
}
|
|
102
105
|
]
|
|
103
106
|
}
|
|
104
107
|
}
|
|
@@ -114,9 +117,8 @@ test("discoverShellOutletTargetsFromApp includes installed package placement out
|
|
|
114
117
|
);
|
|
115
118
|
assert.deepEqual(discovered.targets[1], {
|
|
116
119
|
id: "workspace-tools:primary-menu",
|
|
117
|
-
host: "workspace-tools",
|
|
118
|
-
position: "primary-menu",
|
|
119
120
|
default: false,
|
|
121
|
+
defaultLinkComponentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
120
122
|
sourcePath: "package:@example/users-web:src/client/components/UsersWorkspaceToolsWidget.vue",
|
|
121
123
|
sourcePackageId: "@example/users-web"
|
|
122
124
|
});
|
|
@@ -130,6 +132,68 @@ test("discoverShellOutletTargetsFromApp includes installed package placement out
|
|
|
130
132
|
});
|
|
131
133
|
});
|
|
132
134
|
|
|
135
|
+
test("discoverShellOutletTargetsFromApp applies app config default-link overrides by target id", async () => {
|
|
136
|
+
await withTempApp(async (appRoot) => {
|
|
137
|
+
await writeFileInApp(
|
|
138
|
+
appRoot,
|
|
139
|
+
"config/public.js",
|
|
140
|
+
`export const config = {
|
|
141
|
+
ui: {
|
|
142
|
+
outletDefaults: {
|
|
143
|
+
"workspace-tools:primary-menu": {
|
|
144
|
+
linkComponentToken: "local.main.ui.surface-aware-menu-link-item"
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
`
|
|
150
|
+
);
|
|
151
|
+
await writeFileInApp(
|
|
152
|
+
appRoot,
|
|
153
|
+
".jskit/lock.json",
|
|
154
|
+
`${JSON.stringify(
|
|
155
|
+
{
|
|
156
|
+
lockVersion: 1,
|
|
157
|
+
installedPackages: {
|
|
158
|
+
"@example/users-web": {
|
|
159
|
+
packageId: "@example/users-web",
|
|
160
|
+
source: {
|
|
161
|
+
type: "npm-installed-package",
|
|
162
|
+
descriptorPath: "node_modules/@example/users-web/package.descriptor.mjs"
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
null,
|
|
168
|
+
2
|
|
169
|
+
)}\n`
|
|
170
|
+
);
|
|
171
|
+
await writeFileInApp(
|
|
172
|
+
appRoot,
|
|
173
|
+
"node_modules/@example/users-web/package.descriptor.mjs",
|
|
174
|
+
`export default {
|
|
175
|
+
packageId: "@example/users-web",
|
|
176
|
+
metadata: {
|
|
177
|
+
ui: {
|
|
178
|
+
placements: {
|
|
179
|
+
outlets: [
|
|
180
|
+
{
|
|
181
|
+
target: "workspace-tools:primary-menu",
|
|
182
|
+
defaultLinkComponentToken: "local.main.ui.surface-aware-menu-link-item"
|
|
183
|
+
}
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
`
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const discovered = await discoverShellOutletTargetsFromApp({ appRoot });
|
|
193
|
+
assert.equal(discovered.targets[0].defaultLinkComponentToken, "local.main.ui.surface-aware-menu-link-item");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
133
197
|
test("discoverShellOutletTargetsFromApp returns targets with sourcePath and default marker", async () => {
|
|
134
198
|
await withTempApp(async (appRoot) => {
|
|
135
199
|
await writeFileInApp(
|
|
@@ -137,7 +201,7 @@ test("discoverShellOutletTargetsFromApp returns targets with sourcePath and defa
|
|
|
137
201
|
"src/components/ShellLayout.vue",
|
|
138
202
|
`<template>
|
|
139
203
|
<div>
|
|
140
|
-
<ShellOutlet
|
|
204
|
+
<ShellOutlet target="shell-layout:primary-menu" />
|
|
141
205
|
</div>
|
|
142
206
|
</template>
|
|
143
207
|
`
|
|
@@ -147,7 +211,7 @@ test("discoverShellOutletTargetsFromApp returns targets with sourcePath and defa
|
|
|
147
211
|
"src/pages/admin/toolbox/index.vue",
|
|
148
212
|
`<template>
|
|
149
213
|
<section>
|
|
150
|
-
<ShellOutlet
|
|
214
|
+
<ShellOutlet target="admin-toolbox:widgets" default />
|
|
151
215
|
</section>
|
|
152
216
|
</template>
|
|
153
217
|
`
|
|
@@ -158,16 +222,14 @@ test("discoverShellOutletTargetsFromApp returns targets with sourcePath and defa
|
|
|
158
222
|
assert.deepEqual(discovered.targets, [
|
|
159
223
|
{
|
|
160
224
|
id: "admin-toolbox:widgets",
|
|
161
|
-
host: "admin-toolbox",
|
|
162
|
-
position: "widgets",
|
|
163
225
|
default: true,
|
|
226
|
+
defaultLinkComponentToken: "",
|
|
164
227
|
sourcePath: "src/pages/admin/toolbox/index.vue"
|
|
165
228
|
},
|
|
166
229
|
{
|
|
167
230
|
id: "shell-layout:primary-menu",
|
|
168
|
-
host: "shell-layout",
|
|
169
|
-
position: "primary-menu",
|
|
170
231
|
default: false,
|
|
232
|
+
defaultLinkComponentToken: "",
|
|
171
233
|
sourcePath: "src/components/ShellLayout.vue"
|
|
172
234
|
}
|
|
173
235
|
]);
|
|
@@ -188,8 +250,7 @@ test("discoverShellOutletTargetsFromApp discovers route meta placement outlets",
|
|
|
188
250
|
"placements": {
|
|
189
251
|
"outlets": [
|
|
190
252
|
{
|
|
191
|
-
"
|
|
192
|
-
"position": "sub-pages"
|
|
253
|
+
"target": "contact-tools:sub-pages"
|
|
193
254
|
}
|
|
194
255
|
]
|
|
195
256
|
}
|
|
@@ -204,9 +265,8 @@ test("discoverShellOutletTargetsFromApp discovers route meta placement outlets",
|
|
|
204
265
|
assert.deepEqual(discovered.targets, [
|
|
205
266
|
{
|
|
206
267
|
id: "contact-tools:sub-pages",
|
|
207
|
-
host: "contact-tools",
|
|
208
|
-
position: "sub-pages",
|
|
209
268
|
default: false,
|
|
269
|
+
defaultLinkComponentToken: "",
|
|
210
270
|
sourcePath: "src/pages/w/[workspaceSlug]/admin/contacts/[contactId]/contact-tools.vue"
|
|
211
271
|
}
|
|
212
272
|
]);
|
|
@@ -227,8 +287,8 @@ test("resolveShellOutletPlacementTargetFromApp supports explicit placement overr
|
|
|
227
287
|
"src/components/ShellLayout.vue",
|
|
228
288
|
`<template>
|
|
229
289
|
<div>
|
|
230
|
-
<ShellOutlet
|
|
231
|
-
<ShellOutlet
|
|
290
|
+
<ShellOutlet target="shell-layout:primary-menu" default />
|
|
291
|
+
<ShellOutlet target="shell-layout:top-right" />
|
|
232
292
|
</div>
|
|
233
293
|
</template>
|
|
234
294
|
`
|
|
@@ -240,8 +300,7 @@ test("resolveShellOutletPlacementTargetFromApp supports explicit placement overr
|
|
|
240
300
|
placement: "shell-layout:top-right"
|
|
241
301
|
});
|
|
242
302
|
|
|
243
|
-
assert.equal(target.
|
|
244
|
-
assert.equal(target.position, "top-right");
|
|
303
|
+
assert.equal(target.id, "shell-layout:top-right");
|
|
245
304
|
});
|
|
246
305
|
});
|
|
247
306
|
|
|
@@ -252,7 +311,7 @@ test("resolveShellOutletPlacementTargetFromApp validates placement format", asyn
|
|
|
252
311
|
"src/components/ShellLayout.vue",
|
|
253
312
|
`<template>
|
|
254
313
|
<div>
|
|
255
|
-
<ShellOutlet
|
|
314
|
+
<ShellOutlet target="shell-layout:primary-menu" default />
|
|
256
315
|
</div>
|
|
257
316
|
</template>
|
|
258
317
|
`
|
|
@@ -265,7 +324,7 @@ test("resolveShellOutletPlacementTargetFromApp validates placement format", asyn
|
|
|
265
324
|
context: "ui-generator",
|
|
266
325
|
placement: "invalid-placement"
|
|
267
326
|
}),
|
|
268
|
-
/option "placement" must be in "host:position" format/
|
|
327
|
+
/option "placement" must be a target in "host:position" format/
|
|
269
328
|
);
|
|
270
329
|
});
|
|
271
330
|
});
|
|
@@ -277,7 +336,7 @@ test("resolveShellOutletPlacementTargetFromApp throws when multiple default outl
|
|
|
277
336
|
"src/components/ShellLayout.vue",
|
|
278
337
|
`<template>
|
|
279
338
|
<div>
|
|
280
|
-
<ShellOutlet
|
|
339
|
+
<ShellOutlet target="shell-layout:primary-menu" default />
|
|
281
340
|
</div>
|
|
282
341
|
</template>
|
|
283
342
|
`
|
|
@@ -287,7 +346,7 @@ test("resolveShellOutletPlacementTargetFromApp throws when multiple default outl
|
|
|
287
346
|
"src/pages/admin/workspace/settings/index.vue",
|
|
288
347
|
`<template>
|
|
289
348
|
<section>
|
|
290
|
-
<ShellOutlet
|
|
349
|
+
<ShellOutlet target="admin-settings:forms" default />
|
|
291
350
|
</section>
|
|
292
351
|
</template>
|
|
293
352
|
`
|
|
@@ -180,6 +180,34 @@ function normalizePositiveInteger(value, { fallback = 0 } = {}) {
|
|
|
180
180
|
return numeric;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
function normalizeCanonicalRecordIdText(value, { fallback = null } = {}) {
|
|
184
|
+
if (value == null) {
|
|
185
|
+
return fallback;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const normalized = String(value).trim();
|
|
189
|
+
return /^[1-9][0-9]*$/.test(normalized) ? normalized : fallback;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeRecordId(value, { fallback = null } = {}) {
|
|
193
|
+
if (value == null) {
|
|
194
|
+
return fallback;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (typeof value === "string") {
|
|
198
|
+
return normalizeCanonicalRecordIdText(value, { fallback });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (typeof value === "bigint") {
|
|
202
|
+
if (value < 1n) {
|
|
203
|
+
return fallback;
|
|
204
|
+
}
|
|
205
|
+
return normalizeCanonicalRecordIdText(value, { fallback });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return fallback;
|
|
209
|
+
}
|
|
210
|
+
|
|
183
211
|
function normalizeOpaqueId(value, { fallback = null } = {}) {
|
|
184
212
|
if (value == null) {
|
|
185
213
|
return fallback;
|
|
@@ -191,7 +219,7 @@ function normalizeOpaqueId(value, { fallback = null } = {}) {
|
|
|
191
219
|
}
|
|
192
220
|
|
|
193
221
|
if (typeof value === "number") {
|
|
194
|
-
return Number.isFinite(value) ? value : fallback;
|
|
222
|
+
return Number.isFinite(value) ? String(value) : fallback;
|
|
195
223
|
}
|
|
196
224
|
|
|
197
225
|
if (typeof value === "bigint") {
|
|
@@ -244,6 +272,8 @@ export {
|
|
|
244
272
|
normalizeUniqueTextList,
|
|
245
273
|
normalizeInteger,
|
|
246
274
|
normalizePositiveInteger,
|
|
275
|
+
normalizeCanonicalRecordIdText,
|
|
276
|
+
normalizeRecordId,
|
|
247
277
|
normalizeOpaqueId,
|
|
248
278
|
normalizeOneOf,
|
|
249
279
|
ensureNonEmptyText
|
|
@@ -3,11 +3,13 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import {
|
|
4
4
|
hasValue,
|
|
5
5
|
normalizeBoolean,
|
|
6
|
+
normalizeCanonicalRecordIdText,
|
|
6
7
|
normalizeFiniteInteger,
|
|
7
8
|
normalizeFiniteNumber,
|
|
8
9
|
normalizeIfInSource,
|
|
9
10
|
normalizeIfPresent,
|
|
10
11
|
normalizeOrNull,
|
|
12
|
+
normalizeRecordId,
|
|
11
13
|
normalizeOpaqueId,
|
|
12
14
|
normalizePositiveInteger,
|
|
13
15
|
normalizeOneOf,
|
|
@@ -172,10 +174,27 @@ test("normalizeOrNull normalizes non-nullish values and coerces nullish to null"
|
|
|
172
174
|
);
|
|
173
175
|
});
|
|
174
176
|
|
|
177
|
+
test("normalizeRecordId accepts canonical string and bigint identifiers only", () => {
|
|
178
|
+
const unsafeNumericId = Number(9007199254740993n);
|
|
179
|
+
assert.equal(normalizeRecordId(" 7 "), "7");
|
|
180
|
+
assert.equal(normalizeRecordId(10n), "10");
|
|
181
|
+
assert.equal(normalizeRecordId(7), null);
|
|
182
|
+
assert.equal(normalizeRecordId(unsafeNumericId), null);
|
|
183
|
+
assert.equal(normalizeRecordId(""), null);
|
|
184
|
+
assert.equal(normalizeRecordId(null), null);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("normalizeCanonicalRecordIdText validates canonical positive decimal identifiers", () => {
|
|
188
|
+
assert.equal(normalizeCanonicalRecordIdText(" 7 "), "7");
|
|
189
|
+
assert.equal(normalizeCanonicalRecordIdText("007"), null);
|
|
190
|
+
assert.equal(normalizeCanonicalRecordIdText("0"), null);
|
|
191
|
+
assert.equal(normalizeCanonicalRecordIdText("abc"), null);
|
|
192
|
+
});
|
|
193
|
+
|
|
175
194
|
test("normalizeOpaqueId preserves opaque identifiers", () => {
|
|
176
195
|
assert.equal(normalizeOpaqueId(" user-123 "), "user-123");
|
|
177
|
-
assert.equal(normalizeOpaqueId(7), 7);
|
|
178
|
-
assert.equal(normalizeOpaqueId(0), 0);
|
|
196
|
+
assert.equal(normalizeOpaqueId(7), "7");
|
|
197
|
+
assert.equal(normalizeOpaqueId(0), "0");
|
|
179
198
|
assert.equal(normalizeOpaqueId(10n), "10");
|
|
180
199
|
assert.equal(normalizeOpaqueId(""), null);
|
|
181
200
|
assert.equal(normalizeOpaqueId(null), null);
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
normalizeObject,
|
|
3
|
+
normalizeText
|
|
4
|
+
} from "./normalize.js";
|
|
2
5
|
|
|
3
6
|
const SHELL_OUTLET_TAG_PATTERN = /<ShellOutlet\b([^>]*)\/?>/g;
|
|
4
7
|
const ATTRIBUTE_PATTERN = /([:@]?[A-Za-z_][A-Za-z0-9_-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g;
|
|
@@ -40,6 +43,21 @@ function normalizeShellOutletTargetId(value = "") {
|
|
|
40
43
|
return `${host}:${position}`;
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
function resolveShellOutletTargetParts(
|
|
47
|
+
{
|
|
48
|
+
target = ""
|
|
49
|
+
} = {}
|
|
50
|
+
) {
|
|
51
|
+
const normalizedTargetId = normalizeShellOutletTargetId(target);
|
|
52
|
+
if (!normalizedTargetId) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
id: normalizedTargetId
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
43
61
|
function findShellOutletTargetById(targets = [], targetId = "") {
|
|
44
62
|
const entries = Array.isArray(targets) ? targets : [];
|
|
45
63
|
const normalizedTargetId = normalizeShellOutletTargetId(targetId);
|
|
@@ -70,6 +88,42 @@ function isDefaultAttributeEnabled(value) {
|
|
|
70
88
|
return normalized !== "false" && normalized !== "0" && normalized !== "no" && normalized !== "off";
|
|
71
89
|
}
|
|
72
90
|
|
|
91
|
+
function normalizeShellOutletTargetRecord(
|
|
92
|
+
value = {},
|
|
93
|
+
{
|
|
94
|
+
context = "shell layout"
|
|
95
|
+
} = {}
|
|
96
|
+
) {
|
|
97
|
+
const record = normalizeObject(value);
|
|
98
|
+
const resolvedContext = normalizeText(context) || "shell layout";
|
|
99
|
+
if (Object.hasOwn(record, "host") || Object.hasOwn(record, "position")) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`${resolvedContext} must declare ShellOutlet targets with "target" only. ` +
|
|
102
|
+
`Legacy "host" and "position" attributes are not supported.`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const targetParts = resolveShellOutletTargetParts(
|
|
107
|
+
{
|
|
108
|
+
target: record.target
|
|
109
|
+
},
|
|
110
|
+
{ context: resolvedContext }
|
|
111
|
+
);
|
|
112
|
+
if (!targetParts) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return Object.freeze({
|
|
117
|
+
...targetParts,
|
|
118
|
+
default:
|
|
119
|
+
Object.hasOwn(record, "default") &&
|
|
120
|
+
isDefaultAttributeEnabled(record.default),
|
|
121
|
+
defaultLinkComponentToken:
|
|
122
|
+
normalizeText(record.defaultLinkComponentToken) ||
|
|
123
|
+
normalizeText(record["default-link-component-token"])
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
73
127
|
function discoverShellOutletTargetsFromVueSource(source = "", { context = "shell layout" } = {}) {
|
|
74
128
|
const sourceText = String(source || "");
|
|
75
129
|
const resolvedContext = normalizeText(context) || "shell layout";
|
|
@@ -78,39 +132,26 @@ function discoverShellOutletTargetsFromVueSource(source = "", { context = "shell
|
|
|
78
132
|
|
|
79
133
|
for (const tagMatch of sourceText.matchAll(SHELL_OUTLET_TAG_PATTERN)) {
|
|
80
134
|
const attributes = parseTagAttributes(tagMatch[1]);
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
135
|
+
const normalizedTarget = normalizeShellOutletTargetRecord(attributes, {
|
|
136
|
+
context: resolvedContext
|
|
137
|
+
});
|
|
138
|
+
if (!normalizedTarget) {
|
|
84
139
|
continue;
|
|
85
140
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (!id) {
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
if (targetById.has(id)) {
|
|
92
|
-
throw new Error(`${resolvedContext} contains duplicate ShellOutlet target "${id}".`);
|
|
141
|
+
if (targetById.has(normalizedTarget.id)) {
|
|
142
|
+
throw new Error(`${resolvedContext} contains duplicate ShellOutlet target "${normalizedTarget.id}".`);
|
|
93
143
|
}
|
|
94
144
|
|
|
95
|
-
|
|
96
|
-
if (hasDefaultAttribute) {
|
|
145
|
+
if (normalizedTarget.default) {
|
|
97
146
|
if (defaultTargetId) {
|
|
98
147
|
throw new Error(
|
|
99
|
-
`${resolvedContext} defines multiple default ShellOutlet targets: "${defaultTargetId}" and "${id}".`
|
|
148
|
+
`${resolvedContext} defines multiple default ShellOutlet targets: "${defaultTargetId}" and "${normalizedTarget.id}".`
|
|
100
149
|
);
|
|
101
150
|
}
|
|
102
|
-
defaultTargetId = id;
|
|
151
|
+
defaultTargetId = normalizedTarget.id;
|
|
103
152
|
}
|
|
104
153
|
|
|
105
|
-
targetById.set(
|
|
106
|
-
id,
|
|
107
|
-
Object.freeze({
|
|
108
|
-
id,
|
|
109
|
-
host,
|
|
110
|
-
position,
|
|
111
|
-
default: hasDefaultAttribute
|
|
112
|
-
})
|
|
113
|
-
);
|
|
154
|
+
targetById.set(normalizedTarget.id, normalizedTarget);
|
|
114
155
|
}
|
|
115
156
|
|
|
116
157
|
return Object.freeze({
|
|
@@ -123,5 +164,7 @@ export {
|
|
|
123
164
|
describeShellOutletTargets,
|
|
124
165
|
discoverShellOutletTargetsFromVueSource,
|
|
125
166
|
findShellOutletTargetById,
|
|
126
|
-
normalizeShellOutletTargetId
|
|
167
|
+
normalizeShellOutletTargetId,
|
|
168
|
+
normalizeShellOutletTargetRecord,
|
|
169
|
+
resolveShellOutletTargetParts
|
|
127
170
|
};
|