@jskit-ai/kernel 0.1.33 → 0.1.35
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/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/shellLayoutTargets.js +68 -25
- package/shared/support/shellLayoutTargets.test.js +27 -9
- package/README.md +0 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/kernel",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.35",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"typebox": "^1.0.81"
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"./shared/support/crudLookup": "./shared/support/crudLookup.js",
|
|
38
38
|
"./shared/support/deepFreeze": "./shared/support/deepFreeze.js",
|
|
39
39
|
"./shared/support/listenerSet": "./shared/support/listenerSet.js",
|
|
40
|
+
"./shared/support/shellLayoutTargets": "./shared/support/shellLayoutTargets.js",
|
|
40
41
|
"./shared/support/providerLogger": "./shared/support/providerLogger.js",
|
|
41
42
|
"./shared/support/returnToPath": "./shared/support/returnToPath.js",
|
|
42
43
|
"./shared/support/visibility": "./shared/support/visibility.js",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
|
|
4
|
+
let freshImportCounter = 0;
|
|
5
|
+
|
|
6
|
+
function nextFreshImportToken() {
|
|
7
|
+
freshImportCounter += 1;
|
|
8
|
+
return String(freshImportCounter);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function importFreshModuleFromAbsolutePath(absolutePath) {
|
|
12
|
+
const normalizedPath = String(absolutePath || "").trim();
|
|
13
|
+
if (!normalizedPath || !path.isAbsolute(normalizedPath)) {
|
|
14
|
+
throw new Error("importFreshModuleFromAbsolutePath requires an absolute path.");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const resolvedPath = path.resolve(normalizedPath);
|
|
18
|
+
const moduleUrl = pathToFileURL(resolvedPath);
|
|
19
|
+
moduleUrl.searchParams.set("jskit_fresh", nextFreshImportToken());
|
|
20
|
+
return import(moduleUrl.href);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { importFreshModuleFromAbsolutePath };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, 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 { importFreshModuleFromAbsolutePath } from "./importFreshModuleFromAbsolutePath.js";
|
|
7
|
+
|
|
8
|
+
test("importFreshModuleFromAbsolutePath requires an absolute path", async () => {
|
|
9
|
+
await assert.rejects(
|
|
10
|
+
() => importFreshModuleFromAbsolutePath("relative/module.js"),
|
|
11
|
+
/requires an absolute path/
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("importFreshModuleFromAbsolutePath re-evaluates a module on each call", async () => {
|
|
16
|
+
const tempRoot = await mkdtemp(path.join(tmpdir(), "jskit-import-fresh-module-"));
|
|
17
|
+
const modulePath = path.join(tempRoot, "counter.mjs");
|
|
18
|
+
const counterKey = "__jskitKernelImportFreshCounter";
|
|
19
|
+
delete globalThis[counterKey];
|
|
20
|
+
|
|
21
|
+
await writeFile(
|
|
22
|
+
modulePath,
|
|
23
|
+
`globalThis.${counterKey} = Number(globalThis.${counterKey} || 0) + 1;
|
|
24
|
+
export const loadCount = globalThis.${counterKey};
|
|
25
|
+
`,
|
|
26
|
+
"utf8"
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const first = await importFreshModuleFromAbsolutePath(modulePath);
|
|
30
|
+
const second = await importFreshModuleFromAbsolutePath(modulePath);
|
|
31
|
+
|
|
32
|
+
assert.equal(first.loadCount, 1);
|
|
33
|
+
assert.equal(second.loadCount, 2);
|
|
34
|
+
|
|
35
|
+
delete globalThis[counterKey];
|
|
36
|
+
});
|
package/server/support/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { symlinkSafeRequire } from "./symlinkSafeRequire.js";
|
|
2
2
|
export { resolveAppConfig } from "./appConfig.js";
|
|
3
3
|
export { loadAppConfigFromModuleUrl } from "./appConfigFiles.js";
|
|
4
|
+
export { importFreshModuleFromAbsolutePath } from "./importFreshModuleFromAbsolutePath.js";
|
|
4
5
|
export { resolveRequiredAppRoot, toPosixPath } from "./path.js";
|
|
5
6
|
export {
|
|
6
7
|
DEFAULT_PAGE_LINK_COMPONENT_TOKEN,
|
|
@@ -16,8 +16,9 @@ import {
|
|
|
16
16
|
} from "../../shared/support/shellLayoutTargets.js";
|
|
17
17
|
import { resolveShellOutletPlacementTargetFromApp } from "./shellOutlets.js";
|
|
18
18
|
import { resolveRequiredAppRoot, toPosixPath } from "./path.js";
|
|
19
|
+
import { loadAppConfigFromModuleUrl } from "./appConfigFiles.js";
|
|
19
20
|
|
|
20
|
-
const DEFAULT_PAGE_LINK_COMPONENT_TOKEN = "
|
|
21
|
+
const DEFAULT_PAGE_LINK_COMPONENT_TOKEN = "local.main.ui.surface-aware-menu-link-item";
|
|
21
22
|
const DEFAULT_SUBPAGE_LINK_COMPONENT_TOKEN = "local.main.ui.tab-link-item";
|
|
22
23
|
const PAGE_ROOT_PREFIX = "src/pages/";
|
|
23
24
|
const ROUTER_VIEW_TAG_PATTERN = /<RouterView\b/i;
|
|
@@ -64,17 +65,17 @@ function resolvePagesRelativeAppPath(
|
|
|
64
65
|
if (isAbsolutePathInput(normalizedValue)) {
|
|
65
66
|
throw new Error(`${context} ${label} must be relative to src/pages/: ${normalizedValue}.`);
|
|
66
67
|
}
|
|
67
|
-
if (
|
|
68
|
-
normalizedValue === "src/pages" ||
|
|
69
|
-
normalizedValue.startsWith(PAGE_ROOT_PREFIX)
|
|
70
|
-
) {
|
|
68
|
+
if (normalizedValue === "src/pages" || normalizedValue === PAGE_ROOT_PREFIX) {
|
|
71
69
|
throw new Error(
|
|
72
|
-
`${context} ${label} must
|
|
70
|
+
`${context} ${label} must include a path under src/pages/: ${normalizedValue}.`
|
|
73
71
|
);
|
|
74
72
|
}
|
|
73
|
+
if (normalizedValue.startsWith(PAGE_ROOT_PREFIX)) {
|
|
74
|
+
return normalizedValue;
|
|
75
|
+
}
|
|
75
76
|
if (normalizedValue.startsWith("src/")) {
|
|
76
77
|
throw new Error(
|
|
77
|
-
`${context} ${label} must be relative to src/pages
|
|
78
|
+
`${context} ${label} must be relative to src/pages/ or start with src/pages/: ${normalizedValue}.`
|
|
78
79
|
);
|
|
79
80
|
}
|
|
80
81
|
|
|
@@ -174,38 +175,46 @@ function humanizePageSegment(value = "", fallback = "Page") {
|
|
|
174
175
|
|
|
175
176
|
async function loadPublicConfig(appRoot = "", { context = "page target" } = {}) {
|
|
176
177
|
const resolvedAppRoot = resolveRequiredAppRoot(appRoot, { context });
|
|
177
|
-
const configPath = path.join(resolvedAppRoot, "config", "public.js");
|
|
178
|
-
|
|
179
|
-
try {
|
|
180
|
-
await readFile(configPath, "utf8");
|
|
181
|
-
} catch {
|
|
182
|
-
throw new Error(`${context} requires app config at config/public.js.`);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
let moduleNamespace = null;
|
|
186
178
|
try {
|
|
187
|
-
|
|
179
|
+
const config = normalizeObject(
|
|
180
|
+
await loadAppConfigFromModuleUrl({
|
|
181
|
+
moduleUrl: pathToFileURL(path.join(resolvedAppRoot, "config", "public.js")).href
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
if (Object.keys(config).length < 1) {
|
|
185
|
+
throw new Error("requires exported config in config/public.js.");
|
|
186
|
+
}
|
|
187
|
+
return config;
|
|
188
188
|
} catch (error) {
|
|
189
189
|
throw new Error(
|
|
190
190
|
`${context} could not load config/public.js: ${String(error?.message || error || "unknown error")}`
|
|
191
191
|
);
|
|
192
192
|
}
|
|
193
|
+
}
|
|
193
194
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
195
|
+
function normalizeSurfaceAccessPolicyId(value = "") {
|
|
196
|
+
return normalizeText(value).toLowerCase();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function resolveSurfaceRequiresAuth(surfaceDefinition = {}, surfaceAccessPolicies = {}) {
|
|
200
|
+
const normalizedDefinition = normalizeObject(surfaceDefinition);
|
|
201
|
+
const normalizedPolicies = normalizeObject(surfaceAccessPolicies);
|
|
202
|
+
const accessPolicyId = normalizeSurfaceAccessPolicyId(normalizedDefinition.accessPolicyId);
|
|
203
|
+
const configuredPolicy = accessPolicyId
|
|
204
|
+
? normalizeObject(normalizedPolicies[accessPolicyId])
|
|
205
|
+
: {};
|
|
206
|
+
|
|
207
|
+
if (Object.hasOwn(configuredPolicy, "requireAuth")) {
|
|
208
|
+
return configuredPolicy.requireAuth === true;
|
|
201
209
|
}
|
|
202
210
|
|
|
203
|
-
return
|
|
211
|
+
return normalizedDefinition.requiresAuth === true;
|
|
204
212
|
}
|
|
205
213
|
|
|
206
214
|
async function listSurfacePageRoots(appRoot = "", { context = "page target" } = {}) {
|
|
207
215
|
const config = await loadPublicConfig(appRoot, { context });
|
|
208
216
|
const surfaceDefinitions = normalizeObject(config.surfaceDefinitions);
|
|
217
|
+
const surfaceAccessPolicies = normalizeObject(config.surfaceAccessPolicies);
|
|
209
218
|
|
|
210
219
|
return Object.freeze(
|
|
211
220
|
Object.entries(surfaceDefinitions)
|
|
@@ -218,7 +227,8 @@ async function listSurfacePageRoots(appRoot = "", { context = "page target" } =
|
|
|
218
227
|
|
|
219
228
|
return Object.freeze({
|
|
220
229
|
id: surfaceId,
|
|
221
|
-
pagesRoot: normalizeSurfacePagesRoot(definition.pagesRoot)
|
|
230
|
+
pagesRoot: normalizeSurfacePagesRoot(definition.pagesRoot),
|
|
231
|
+
requiresAuth: resolveSurfaceRequiresAuth(definition, surfaceAccessPolicies)
|
|
222
232
|
});
|
|
223
233
|
})
|
|
224
234
|
.filter(Boolean)
|
|
@@ -239,6 +249,7 @@ function deriveSurfaceMatchesFromPageFile(relativePath = "", surfacePageRoots =
|
|
|
239
249
|
return Object.freeze({
|
|
240
250
|
surfaceId: normalizeSurfaceId(surface?.id),
|
|
241
251
|
pagesRoot,
|
|
252
|
+
requiresAuth: surface?.requiresAuth === true,
|
|
242
253
|
surfaceRelativeFilePath: pagePathWithinPagesRoot
|
|
243
254
|
});
|
|
244
255
|
}
|
|
@@ -251,6 +262,7 @@ function deriveSurfaceMatchesFromPageFile(relativePath = "", surfacePageRoots =
|
|
|
251
262
|
return Object.freeze({
|
|
252
263
|
surfaceId: normalizeSurfaceId(surface?.id),
|
|
253
264
|
pagesRoot,
|
|
265
|
+
requiresAuth: surface?.requiresAuth === true,
|
|
254
266
|
surfaceRelativeFilePath: pagePathWithinPagesRoot.slice(requiredPrefix.length)
|
|
255
267
|
});
|
|
256
268
|
})
|
|
@@ -433,9 +445,7 @@ function resolveSubpagesHostTargetFromPageSource(source = "") {
|
|
|
433
445
|
}
|
|
434
446
|
|
|
435
447
|
return Object.freeze({
|
|
436
|
-
id: target.id
|
|
437
|
-
host: target.host,
|
|
438
|
-
position: target.position
|
|
448
|
+
id: target.id
|
|
439
449
|
});
|
|
440
450
|
}
|
|
441
451
|
|
|
@@ -468,6 +478,7 @@ async function resolvePageTargetDetails({
|
|
|
468
478
|
}),
|
|
469
479
|
surfaceId: surfaceMatch.surfaceId,
|
|
470
480
|
surfacePagesRoot: surfaceMatch.pagesRoot,
|
|
481
|
+
surfaceRequiresAuth: surfaceMatch.requiresAuth === true,
|
|
471
482
|
surfaceRelativeFilePath: surfaceMatch.surfaceRelativeFilePath,
|
|
472
483
|
...routeInfo
|
|
473
484
|
});
|
|
@@ -530,12 +541,7 @@ async function resolveNearestParentSubpagesHost({
|
|
|
530
541
|
}
|
|
531
542
|
|
|
532
543
|
function normalizePlacementTargetId(target = {}) {
|
|
533
|
-
|
|
534
|
-
const position = normalizeText(target?.position);
|
|
535
|
-
if (!host || !position) {
|
|
536
|
-
return "";
|
|
537
|
-
}
|
|
538
|
-
return normalizeShellOutletTargetId(`${host}:${position}`);
|
|
544
|
+
return normalizeShellOutletTargetId(target?.id || target?.target || target);
|
|
539
545
|
}
|
|
540
546
|
|
|
541
547
|
function resolveRelativeLinkToFromParent(pageTarget = {}, parentHost = null) {
|
|
@@ -616,6 +622,11 @@ function resolveInferredPageLinkComponentToken({
|
|
|
616
622
|
return normalizedExplicitToken;
|
|
617
623
|
}
|
|
618
624
|
|
|
625
|
+
const normalizedPlacementTargetDefaultToken = normalizeText(placementTarget?.defaultLinkComponentToken);
|
|
626
|
+
if (normalizedPlacementTargetDefaultToken) {
|
|
627
|
+
return normalizedPlacementTargetDefaultToken;
|
|
628
|
+
}
|
|
629
|
+
|
|
619
630
|
const parentTargetId = normalizePlacementTargetId(parentHost);
|
|
620
631
|
const placementTargetId = normalizePlacementTargetId(placementTarget);
|
|
621
632
|
if (parentTargetId && parentTargetId === placementTargetId) {
|
|
@@ -625,6 +636,14 @@ function resolveInferredPageLinkComponentToken({
|
|
|
625
636
|
return normalizeText(defaultComponentToken) || DEFAULT_PAGE_LINK_COMPONENT_TOKEN;
|
|
626
637
|
}
|
|
627
638
|
|
|
639
|
+
function renderPageLinkWhenLine(pageTarget = {}) {
|
|
640
|
+
if (pageTarget?.surfaceRequiresAuth !== true) {
|
|
641
|
+
return "";
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return " when: ({ auth }) => Boolean(auth?.authenticated)\n";
|
|
645
|
+
}
|
|
646
|
+
|
|
628
647
|
async function resolvePageLinkTargetDetails({
|
|
629
648
|
appRoot,
|
|
630
649
|
targetFile = "",
|
|
@@ -663,6 +682,7 @@ async function resolvePageLinkTargetDetails({
|
|
|
663
682
|
defaultComponentToken,
|
|
664
683
|
subpageComponentToken
|
|
665
684
|
}),
|
|
685
|
+
whenLine: renderPageLinkWhenLine(resolvedPageTarget),
|
|
666
686
|
linkTo: resolveInferredPageLinkTo({
|
|
667
687
|
explicitLinkTo: linkTo,
|
|
668
688
|
pageTarget: resolvedPageTarget,
|
|
@@ -36,8 +36,12 @@ async function writeShellLayout(appRoot, source = "") {
|
|
|
36
36
|
source ||
|
|
37
37
|
`<template>
|
|
38
38
|
<div>
|
|
39
|
-
<ShellOutlet
|
|
40
|
-
<ShellOutlet
|
|
39
|
+
<ShellOutlet target="shell-layout:top-right" />
|
|
40
|
+
<ShellOutlet
|
|
41
|
+
target="shell-layout:primary-menu"
|
|
42
|
+
default
|
|
43
|
+
default-link-component-token="local.main.ui.surface-aware-menu-link-item"
|
|
44
|
+
/>
|
|
41
45
|
</div>
|
|
42
46
|
</template>
|
|
43
47
|
`
|
|
@@ -128,6 +132,41 @@ test("resolvePageTargetDetails chooses the most specific matching surface pagesR
|
|
|
128
132
|
});
|
|
129
133
|
});
|
|
130
134
|
|
|
135
|
+
test("resolvePageTargetDetails derives surface auth requirement from surface access policy", async () => {
|
|
136
|
+
await withTempApp(async (appRoot) => {
|
|
137
|
+
await writeConfig(
|
|
138
|
+
appRoot,
|
|
139
|
+
`export const config = {
|
|
140
|
+
surfaceAccessPolicies: {
|
|
141
|
+
public: {},
|
|
142
|
+
authenticated: {
|
|
143
|
+
requireAuth: true
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
surfaceDefinitions: {
|
|
147
|
+
home: { id: "home", pagesRoot: "home", enabled: true, accessPolicyId: "public" },
|
|
148
|
+
app: { id: "app", pagesRoot: "app", enabled: true, accessPolicyId: "authenticated" }
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
`
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const publicPageTarget = await resolvePageTargetDetails({
|
|
155
|
+
appRoot,
|
|
156
|
+
targetFile: "home/index.vue",
|
|
157
|
+
context: "page target"
|
|
158
|
+
});
|
|
159
|
+
const authenticatedPageTarget = await resolvePageTargetDetails({
|
|
160
|
+
appRoot,
|
|
161
|
+
targetFile: "app/index.vue",
|
|
162
|
+
context: "page target"
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
assert.equal(publicPageTarget.surfaceRequiresAuth, false);
|
|
166
|
+
assert.equal(authenticatedPageTarget.surfaceRequiresAuth, true);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
131
170
|
test("resolvePageTargetDetails rejects duplicate matching surface pagesRoot definitions", async () => {
|
|
132
171
|
await withTempApp(async (appRoot) => {
|
|
133
172
|
await writeConfig(
|
|
@@ -153,7 +192,7 @@ test("resolvePageTargetDetails rejects duplicate matching surface pagesRoot defi
|
|
|
153
192
|
});
|
|
154
193
|
});
|
|
155
194
|
|
|
156
|
-
test("resolvePageTargetDetails
|
|
195
|
+
test("resolvePageTargetDetails accepts target files with a src/pages prefix", async () => {
|
|
157
196
|
await withTempApp(async (appRoot) => {
|
|
158
197
|
await writeConfig(
|
|
159
198
|
appRoot,
|
|
@@ -165,26 +204,25 @@ test("resolvePageTargetDetails rejects target files with a src/pages prefix", as
|
|
|
165
204
|
`
|
|
166
205
|
);
|
|
167
206
|
|
|
168
|
-
await
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
);
|
|
207
|
+
const details = await resolvePageTargetDetails({
|
|
208
|
+
appRoot,
|
|
209
|
+
targetFile: "src/pages/admin/reports/index.vue",
|
|
210
|
+
context: "page target"
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
assert.equal(details.targetFilePath.relativePath, "src/pages/admin/reports/index.vue");
|
|
214
|
+
assert.equal(details.surfaceId, "admin");
|
|
215
|
+
assert.equal(details.routeUrlSuffix, "/reports");
|
|
177
216
|
});
|
|
178
217
|
});
|
|
179
218
|
|
|
180
|
-
test("normalizePagesRelativeTargetRoot
|
|
181
|
-
assert.
|
|
182
|
-
(
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
/must be relative to src\/pages\/, without the src\/pages\/ prefix/
|
|
219
|
+
test("normalizePagesRelativeTargetRoot accepts route roots with a src/pages prefix", () => {
|
|
220
|
+
assert.equal(
|
|
221
|
+
normalizePagesRelativeTargetRoot("src/pages/admin/customers", {
|
|
222
|
+
context: "crud-ui-generator",
|
|
223
|
+
label: 'option "target-root"'
|
|
224
|
+
}),
|
|
225
|
+
"src/pages/admin/customers"
|
|
188
226
|
);
|
|
189
227
|
});
|
|
190
228
|
|
|
@@ -208,10 +246,77 @@ test("resolvePageLinkTargetDetails falls back to the app default placement targe
|
|
|
208
246
|
});
|
|
209
247
|
|
|
210
248
|
assert.equal(details.pageTarget.surfaceId, "admin");
|
|
211
|
-
assert.equal(details.placementTarget.
|
|
212
|
-
assert.equal(details.
|
|
213
|
-
assert.equal(details.componentToken, "users.web.shell.surface-aware-menu-link-item");
|
|
249
|
+
assert.equal(details.placementTarget.id, "shell-layout:primary-menu");
|
|
250
|
+
assert.equal(details.componentToken, "local.main.ui.surface-aware-menu-link-item");
|
|
214
251
|
assert.equal(details.linkTo, "");
|
|
252
|
+
assert.equal(details.whenLine, "");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("resolvePageLinkTargetDetails emits an auth guard when the surface policy requires auth", async () => {
|
|
257
|
+
await withTempApp(async (appRoot) => {
|
|
258
|
+
await writeConfig(
|
|
259
|
+
appRoot,
|
|
260
|
+
`export const config = {
|
|
261
|
+
surfaceAccessPolicies: {
|
|
262
|
+
authenticated: {
|
|
263
|
+
requireAuth: true
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
surfaceDefinitions: {
|
|
267
|
+
app: { id: "app", pagesRoot: "app", enabled: true, accessPolicyId: "authenticated" }
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
`
|
|
271
|
+
);
|
|
272
|
+
await writeShellLayout(appRoot);
|
|
273
|
+
|
|
274
|
+
const details = await resolvePageLinkTargetDetails({
|
|
275
|
+
appRoot,
|
|
276
|
+
targetFile: "app/reports/index.vue",
|
|
277
|
+
context: "page target"
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
assert.equal(details.whenLine, " when: ({ auth }) => Boolean(auth?.authenticated)\n");
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("resolvePageLinkTargetDetails prefers an outlet-declared default link token over subpage heuristics", async () => {
|
|
285
|
+
await withTempApp(async (appRoot) => {
|
|
286
|
+
await writeConfig(
|
|
287
|
+
appRoot,
|
|
288
|
+
`export const config = {
|
|
289
|
+
surfaceDefinitions: {
|
|
290
|
+
home: { id: "home", pagesRoot: "home", enabled: true }
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
`
|
|
294
|
+
);
|
|
295
|
+
await writeFileInApp(
|
|
296
|
+
appRoot,
|
|
297
|
+
"src/pages/home/settings.vue",
|
|
298
|
+
`<template>
|
|
299
|
+
<section>
|
|
300
|
+
<ShellOutlet
|
|
301
|
+
target="home-settings:primary-menu"
|
|
302
|
+
default-link-component-token="local.main.ui.surface-aware-menu-link-item"
|
|
303
|
+
/>
|
|
304
|
+
<RouterView />
|
|
305
|
+
</section>
|
|
306
|
+
</template>
|
|
307
|
+
`
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const details = await resolvePageLinkTargetDetails({
|
|
311
|
+
appRoot,
|
|
312
|
+
targetFile: "home/settings/pollen-types/index.vue",
|
|
313
|
+
context: "page target"
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
assert.equal(details.parentHost?.id, "home-settings:primary-menu");
|
|
317
|
+
assert.equal(details.placementTarget.id, "home-settings:primary-menu");
|
|
318
|
+
assert.equal(details.componentToken, "local.main.ui.surface-aware-menu-link-item");
|
|
319
|
+
assert.equal(details.linkTo, "./pollen-types");
|
|
215
320
|
});
|
|
216
321
|
});
|
|
217
322
|
|
|
@@ -233,7 +338,7 @@ test("resolvePageLinkTargetDetails inherits a file-route parent subpages host",
|
|
|
233
338
|
`<template>
|
|
234
339
|
<SectionContainerShell>
|
|
235
340
|
<template #tabs>
|
|
236
|
-
<ShellOutlet
|
|
341
|
+
<ShellOutlet target="contact-view:sub-pages" />
|
|
237
342
|
</template>
|
|
238
343
|
<RouterView />
|
|
239
344
|
</SectionContainerShell>
|
|
@@ -248,8 +353,7 @@ test("resolvePageLinkTargetDetails inherits a file-route parent subpages host",
|
|
|
248
353
|
});
|
|
249
354
|
|
|
250
355
|
assert.equal(details.parentHost?.id, "contact-view:sub-pages");
|
|
251
|
-
assert.equal(details.placementTarget.
|
|
252
|
-
assert.equal(details.placementTarget.position, "sub-pages");
|
|
356
|
+
assert.equal(details.placementTarget.id, "contact-view:sub-pages");
|
|
253
357
|
assert.equal(details.componentToken, "local.main.ui.tab-link-item");
|
|
254
358
|
assert.equal(details.linkTo, "./notes");
|
|
255
359
|
});
|
|
@@ -277,8 +381,7 @@ test("resolvePageLinkTargetDetails honors explicit placement and link overrides"
|
|
|
277
381
|
context: "page target"
|
|
278
382
|
});
|
|
279
383
|
|
|
280
|
-
assert.equal(details.placementTarget.
|
|
281
|
-
assert.equal(details.placementTarget.position, "top-right");
|
|
384
|
+
assert.equal(details.placementTarget.id, "shell-layout:top-right");
|
|
282
385
|
assert.equal(details.componentToken, "custom.link-item");
|
|
283
386
|
assert.equal(details.linkTo, "./assistant-notes");
|
|
284
387
|
});
|
|
@@ -302,7 +405,7 @@ test("resolvePageLinkTargetDetails inherits an index-route parent subpages host
|
|
|
302
405
|
`<template>
|
|
303
406
|
<SectionContainerShell>
|
|
304
407
|
<template #tabs>
|
|
305
|
-
<ShellOutlet
|
|
408
|
+
<ShellOutlet target="customer-view:sub-pages" />
|
|
306
409
|
</template>
|
|
307
410
|
<RouterView />
|
|
308
411
|
</SectionContainerShell>
|
|
@@ -318,8 +421,7 @@ test("resolvePageLinkTargetDetails inherits an index-route parent subpages host
|
|
|
318
421
|
|
|
319
422
|
assert.equal(details.parentHost?.id, "customer-view:sub-pages");
|
|
320
423
|
assert.equal(details.parentHost?.pageFile, "src/pages/admin/customers/[customerId]/index.vue");
|
|
321
|
-
assert.equal(details.placementTarget.
|
|
322
|
-
assert.equal(details.placementTarget.position, "sub-pages");
|
|
424
|
+
assert.equal(details.placementTarget.id, "customer-view:sub-pages");
|
|
323
425
|
assert.equal(details.componentToken, "local.main.ui.tab-link-item");
|
|
324
426
|
assert.equal(details.linkTo, "./pets");
|
|
325
427
|
});
|
|
@@ -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
|
`
|
|
@@ -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
|
};
|
|
@@ -18,9 +18,13 @@ test("normalizeShellOutletTargetId validates host:position tokens", () => {
|
|
|
18
18
|
test("discoverShellOutletTargetsFromVueSource resolves legal targets and one default", () => {
|
|
19
19
|
const source = `
|
|
20
20
|
<template>
|
|
21
|
-
<ShellOutlet
|
|
22
|
-
<ShellOutlet
|
|
23
|
-
|
|
21
|
+
<ShellOutlet target="shell-layout:top-left" />
|
|
22
|
+
<ShellOutlet
|
|
23
|
+
target="shell-layout:primary-menu"
|
|
24
|
+
default
|
|
25
|
+
default-link-component-token="local.main.ui.surface-aware-menu-link-item"
|
|
26
|
+
/>
|
|
27
|
+
<ShellOutlet target="shell-layout:secondary-menu" />
|
|
24
28
|
</template>
|
|
25
29
|
`;
|
|
26
30
|
|
|
@@ -33,6 +37,7 @@ test("discoverShellOutletTargetsFromVueSource resolves legal targets and one def
|
|
|
33
37
|
discovered.targets.map((entry) => entry.id),
|
|
34
38
|
["shell-layout:top-left", "shell-layout:primary-menu", "shell-layout:secondary-menu"]
|
|
35
39
|
);
|
|
40
|
+
assert.equal(discovered.targets[1].defaultLinkComponentToken, "local.main.ui.surface-aware-menu-link-item");
|
|
36
41
|
assert.equal(
|
|
37
42
|
describeShellOutletTargets(discovered.targets),
|
|
38
43
|
"shell-layout:top-left, shell-layout:primary-menu, shell-layout:secondary-menu"
|
|
@@ -46,8 +51,8 @@ test("discoverShellOutletTargetsFromVueSource resolves legal targets and one def
|
|
|
46
51
|
test("discoverShellOutletTargetsFromVueSource throws for duplicate targets", () => {
|
|
47
52
|
const source = `
|
|
48
53
|
<template>
|
|
49
|
-
<ShellOutlet
|
|
50
|
-
<ShellOutlet
|
|
54
|
+
<ShellOutlet target="shell-layout:top-right" />
|
|
55
|
+
<ShellOutlet target="shell-layout:top-right" />
|
|
51
56
|
</template>
|
|
52
57
|
`;
|
|
53
58
|
|
|
@@ -60,8 +65,8 @@ test("discoverShellOutletTargetsFromVueSource throws for duplicate targets", ()
|
|
|
60
65
|
test("discoverShellOutletTargetsFromVueSource throws for multiple defaults", () => {
|
|
61
66
|
const source = `
|
|
62
67
|
<template>
|
|
63
|
-
<ShellOutlet
|
|
64
|
-
<ShellOutlet
|
|
68
|
+
<ShellOutlet target="shell-layout:primary-menu" default />
|
|
69
|
+
<ShellOutlet target="shell-layout:secondary-menu" default />
|
|
65
70
|
</template>
|
|
66
71
|
`;
|
|
67
72
|
|
|
@@ -74,11 +79,24 @@ test("discoverShellOutletTargetsFromVueSource throws for multiple defaults", ()
|
|
|
74
79
|
test("discoverShellOutletTargetsFromVueSource ignores disabled default markers", () => {
|
|
75
80
|
const source = `
|
|
76
81
|
<template>
|
|
77
|
-
<ShellOutlet
|
|
78
|
-
<ShellOutlet
|
|
82
|
+
<ShellOutlet target="shell-layout:primary-menu" default="false" />
|
|
83
|
+
<ShellOutlet target="shell-layout:secondary-menu" />
|
|
79
84
|
</template>
|
|
80
85
|
`;
|
|
81
86
|
|
|
82
87
|
const discovered = discoverShellOutletTargetsFromVueSource(source, { context: "ShellLayout.vue" });
|
|
83
88
|
assert.equal(discovered.defaultTargetId, "");
|
|
84
89
|
});
|
|
90
|
+
|
|
91
|
+
test("discoverShellOutletTargetsFromVueSource rejects legacy split outlet attributes", () => {
|
|
92
|
+
const source = `
|
|
93
|
+
<template>
|
|
94
|
+
<ShellOutlet target="shell-layout:primary-menu" host="other-host" position="primary-menu" />
|
|
95
|
+
</template>
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
assert.throws(
|
|
99
|
+
() => discoverShellOutletTargetsFromVueSource(source, { context: "ShellLayout.vue" }),
|
|
100
|
+
/must declare ShellOutlet targets with "target" only/
|
|
101
|
+
);
|
|
102
|
+
});
|
package/README.md
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
# kernel
|
|
2
|
-
|
|
3
|
-
Internal JSKIT framework runtime package.
|
|
4
|
-
|
|
5
|
-
This package contains the merged runtime internals:
|
|
6
|
-
- container
|
|
7
|
-
- kernel
|
|
8
|
-
- http fastify bridge
|
|
9
|
-
- server runtime primitives
|
|
10
|
-
- support primitives
|
|
11
|
-
- surface routing primitives
|
|
12
|
-
- platform runtime bootstrap
|
|
13
|
-
|
|
14
|
-
## Historical note
|
|
15
|
-
|
|
16
|
-
kernel was assembled moving code from these legacy packages and then deleting them:
|
|
17
|
-
|
|
18
|
-
- @jskit-ai/container-core
|
|
19
|
-
- @jskit-ai/http-fastify-core
|
|
20
|
-
- @jskit-ai/kernel
|
|
21
|
-
- @jskit-ai/platform-server-runtime
|
|
22
|
-
- @jskit-ai/server-runtime-core
|
|
23
|
-
- @jskit-ai/support-core
|
|
24
|
-
- @jskit-ai/surface-routing
|