@jskit-ai/kernel 0.1.66 → 0.1.67
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/client/moduleBootstrap.js +4 -0
- package/client/moduleBootstrap.test.js +4 -0
- package/client/shellBootstrap.js +2 -0
- package/client/shellBootstrap.test.js +3 -0
- package/client/vite/clientBootstrapPlugin.js +1 -0
- package/client/vite/clientBootstrapPlugin.test.js +3 -3
- package/package.json +2 -1
- package/server/http/lib/httpRuntime.js +3 -1
- package/server/http/lib/kernel.test.js +4 -0
- package/server/runtime/fastifyBootstrap.js +61 -0
- package/server/runtime/fastifyBootstrap.test.js +47 -1
- package/server/support/appConfigFiles.js +3 -2
- package/server/support/appConfigFiles.test.js +15 -0
- package/server/support/index.js +1 -0
- package/server/support/pageTargets.js +24 -8
- package/server/support/pageTargets.test.js +143 -0
- package/server/support/shellOutlets.js +68 -12
- package/server/support/shellOutlets.test.js +31 -0
- package/shared/support/generatedUiContract.js +542 -0
- package/shared/support/generatedUiContract.test.js +208 -0
- package/shared/support/shellLayoutTargets.js +11 -3
- package/shared/support/shellLayoutTargets.test.js +20 -0
|
@@ -483,6 +483,7 @@ function createClientRuntimeApp({
|
|
|
483
483
|
profile = "client",
|
|
484
484
|
app,
|
|
485
485
|
pinia = null,
|
|
486
|
+
queryClient = null,
|
|
486
487
|
router,
|
|
487
488
|
env,
|
|
488
489
|
logger,
|
|
@@ -498,6 +499,7 @@ function createClientRuntimeApp({
|
|
|
498
499
|
runtimeApp.instance("jskit.client.router", router || null);
|
|
499
500
|
runtimeApp.instance("jskit.client.vue.app", app || null);
|
|
500
501
|
runtimeApp.instance("jskit.client.pinia", pinia);
|
|
502
|
+
runtimeApp.instance("jskit.client.query-client", queryClient);
|
|
501
503
|
runtimeApp.instance("jskit.client.env", isRecord(env) ? { ...env } : {});
|
|
502
504
|
runtimeApp.instance("jskit.client.surface.runtime", surfaceRuntime || null);
|
|
503
505
|
runtimeApp.instance("jskit.client.surface.mode", String(surfaceMode || "").trim());
|
|
@@ -510,6 +512,7 @@ async function bootClientModules({
|
|
|
510
512
|
clientModules = [],
|
|
511
513
|
app,
|
|
512
514
|
pinia = null,
|
|
515
|
+
queryClient = null,
|
|
513
516
|
router,
|
|
514
517
|
surfaceRuntime,
|
|
515
518
|
surfaceMode,
|
|
@@ -529,6 +532,7 @@ async function bootClientModules({
|
|
|
529
532
|
profile: String(surfaceRuntime.normalizeSurfaceMode(surfaceMode) || "client"),
|
|
530
533
|
app,
|
|
531
534
|
pinia,
|
|
535
|
+
queryClient,
|
|
532
536
|
router,
|
|
533
537
|
env,
|
|
534
538
|
logger: log,
|
|
@@ -113,6 +113,7 @@ test("bootClientModules registers descriptor and clientRoutes with providers onl
|
|
|
113
113
|
const events = [];
|
|
114
114
|
const loginComponent = {};
|
|
115
115
|
const pinia = { id: "pinia-instance" };
|
|
116
|
+
const queryClient = { id: "query-client-instance" };
|
|
116
117
|
const implicitPinia = { id: "implicit-vue-global-pinia" };
|
|
117
118
|
class ExampleClientProvider {
|
|
118
119
|
static id = "example.client";
|
|
@@ -120,6 +121,7 @@ test("bootClientModules registers descriptor and clientRoutes with providers onl
|
|
|
120
121
|
events.push("register");
|
|
121
122
|
app.instance("example.value", 42);
|
|
122
123
|
app.instance("example.pinia", app.make("jskit.client.pinia"));
|
|
124
|
+
app.instance("example.queryClient", app.make("jskit.client.query-client"));
|
|
123
125
|
}
|
|
124
126
|
boot() {
|
|
125
127
|
events.push("boot");
|
|
@@ -190,6 +192,7 @@ test("bootClientModules registers descriptor and clientRoutes with providers onl
|
|
|
190
192
|
}
|
|
191
193
|
},
|
|
192
194
|
pinia,
|
|
195
|
+
queryClient,
|
|
193
196
|
router,
|
|
194
197
|
surfaceRuntime,
|
|
195
198
|
surfaceMode: "all",
|
|
@@ -206,6 +209,7 @@ test("bootClientModules registers descriptor and clientRoutes with providers onl
|
|
|
206
209
|
assert.equal(router.routes[1].component, loginComponent);
|
|
207
210
|
assert.equal(result.runtimeApp.make("example.value"), 42);
|
|
208
211
|
assert.equal(result.runtimeApp.make("example.pinia"), pinia);
|
|
212
|
+
assert.equal(result.runtimeApp.make("example.queryClient"), queryClient);
|
|
209
213
|
assert.notEqual(result.runtimeApp.make("example.pinia"), implicitPinia);
|
|
210
214
|
});
|
|
211
215
|
|
package/client/shellBootstrap.js
CHANGED
|
@@ -109,6 +109,7 @@ async function bootstrapClientShellApp({
|
|
|
109
109
|
appConfig = {},
|
|
110
110
|
appPlugins = [],
|
|
111
111
|
pinia = null,
|
|
112
|
+
queryClient = null,
|
|
112
113
|
router,
|
|
113
114
|
bootClientModules,
|
|
114
115
|
surfaceRuntime,
|
|
@@ -161,6 +162,7 @@ async function bootstrapClientShellApp({
|
|
|
161
162
|
const clientBootstrap = await bootClientModules({
|
|
162
163
|
app,
|
|
163
164
|
pinia,
|
|
165
|
+
queryClient,
|
|
164
166
|
router,
|
|
165
167
|
surfaceRuntime,
|
|
166
168
|
surfaceMode,
|
|
@@ -86,6 +86,7 @@ test("bootstrapClientShellApp boots modules, reinstalls fallback route, and moun
|
|
|
86
86
|
const surfaceRuntime = createSurfaceRuntimeFixture();
|
|
87
87
|
const plugin = { name: "vuetify-like-plugin" };
|
|
88
88
|
const pinia = { id: "pinia-instance" };
|
|
89
|
+
const queryClient = { id: "query-client-instance" };
|
|
89
90
|
const fallbackRoute = {
|
|
90
91
|
name: "not-found",
|
|
91
92
|
path: "/:pathMatch(.*)*",
|
|
@@ -142,11 +143,13 @@ test("bootstrapClientShellApp boots modules, reinstalls fallback route, and moun
|
|
|
142
143
|
},
|
|
143
144
|
appPlugins: [plugin],
|
|
144
145
|
pinia,
|
|
146
|
+
queryClient,
|
|
145
147
|
router,
|
|
146
148
|
bootClientModules: async (context) => {
|
|
147
149
|
calls.push("bootClientModules");
|
|
148
150
|
assert.equal(context.app, app);
|
|
149
151
|
assert.equal(context.pinia, pinia);
|
|
152
|
+
assert.equal(context.queryClient, queryClient);
|
|
150
153
|
assert.equal(context.router, router);
|
|
151
154
|
assert.equal(typeof context.logger.debug, "function");
|
|
152
155
|
return {
|
|
@@ -14,6 +14,7 @@ const CLIENT_BOOTSTRAP_VIRTUAL_ID = "virtual:jskit-client-bootstrap";
|
|
|
14
14
|
const CLIENT_BOOTSTRAP_RESOLVED_ID = `\0${CLIENT_BOOTSTRAP_VIRTUAL_ID}`;
|
|
15
15
|
const CLIENT_RUNTIME_DEDUPE_SPECIFIERS = Object.freeze([
|
|
16
16
|
"@tanstack/vue-query",
|
|
17
|
+
"pinia",
|
|
17
18
|
"vue",
|
|
18
19
|
"vue-router",
|
|
19
20
|
"vuetify"
|
|
@@ -404,7 +404,7 @@ test("createJskitClientBootstrapPlugin config excludes installed client package
|
|
|
404
404
|
assert.equal(Array.isArray(result?.optimizeDeps?.exclude), true);
|
|
405
405
|
assert.deepEqual(result.optimizeDeps.exclude, ["already/excluded"]);
|
|
406
406
|
assert.deepEqual(result.optimizeDeps.include, ["@example/has-client/client", "a"]);
|
|
407
|
-
assert.deepEqual(result.resolve.dedupe, ["@tanstack/vue-query", "vue", "vue-router", "vuetify"]);
|
|
407
|
+
assert.deepEqual(result.resolve.dedupe, ["@tanstack/vue-query", "pinia", "vue", "vue-router", "vuetify"]);
|
|
408
408
|
} finally {
|
|
409
409
|
process.chdir(previousCwd);
|
|
410
410
|
}
|
|
@@ -469,7 +469,7 @@ test("createJskitClientBootstrapPlugin config excludes local package roots and c
|
|
|
469
469
|
"@example/local-client/shared"
|
|
470
470
|
]);
|
|
471
471
|
assert.deepEqual(result.optimizeDeps.include, ["@example/remote-client/client", "mime-match"]);
|
|
472
|
-
assert.deepEqual(result.resolve.dedupe, ["@tanstack/vue-query", "vue", "vue-router", "vuetify"]);
|
|
472
|
+
assert.deepEqual(result.resolve.dedupe, ["@tanstack/vue-query", "pinia", "vue", "vue-router", "vuetify"]);
|
|
473
473
|
} finally {
|
|
474
474
|
process.chdir(previousCwd);
|
|
475
475
|
}
|
|
@@ -500,7 +500,7 @@ test("createJskitClientBootstrapPlugin config preserves user resolve fields and
|
|
|
500
500
|
assert.deepEqual(result.resolve.alias, {
|
|
501
501
|
"@": "/tmp/app/src"
|
|
502
502
|
});
|
|
503
|
-
assert.deepEqual(result.resolve.dedupe, ["@tanstack/vue-query", "custom-lib", "vue", "vue-router", "vuetify"]);
|
|
503
|
+
assert.deepEqual(result.resolve.dedupe, ["@tanstack/vue-query", "custom-lib", "pinia", "vue", "vue-router", "vuetify"]);
|
|
504
504
|
} finally {
|
|
505
505
|
process.chdir(previousCwd);
|
|
506
506
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/kernel",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.67",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"json-rest-schema": "1.x.x"
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"./shared/support/crudListFilters": "./shared/support/crudListFilters.js",
|
|
43
43
|
"./shared/support/crudFieldContract": "./shared/support/crudFieldContract.js",
|
|
44
44
|
"./shared/support/crudLookup": "./shared/support/crudLookup.js",
|
|
45
|
+
"./shared/support/generatedUiContract": "./shared/support/generatedUiContract.js",
|
|
45
46
|
"./shared/support/deepFreeze": "./shared/support/deepFreeze.js",
|
|
46
47
|
"./shared/support/listenerSet": "./shared/support/listenerSet.js",
|
|
47
48
|
"./shared/support/shellLayoutTargets": "./shared/support/shellLayoutTargets.js",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { normalizeObject } from "../../../shared/support/normalize.js";
|
|
2
|
-
import { ensureApiErrorHandling } from "../../runtime/fastifyBootstrap.js";
|
|
2
|
+
import { ensureApiErrorHandling, registerBodylessContentTypeNormalizer } from "../../runtime/fastifyBootstrap.js";
|
|
3
3
|
import { resolveDefaultSurfaceId } from "../../support/appConfig.js";
|
|
4
4
|
import { RouteRegistrationError } from "./errors.js";
|
|
5
5
|
import { createRouter } from "./router.js";
|
|
@@ -25,6 +25,8 @@ function registerHttpRuntime(app, options = {}) {
|
|
|
25
25
|
defaultSurfaceId: routeRegistrationOptions.requestActionDefaultSurface
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
registerBodylessContentTypeNormalizer(fastify);
|
|
29
|
+
|
|
28
30
|
if (autoRegisterApiErrorHandling !== false) {
|
|
29
31
|
ensureApiErrorHandling(app, {
|
|
30
32
|
fastifyToken,
|
|
@@ -14,9 +14,13 @@ function createFastifyStub() {
|
|
|
14
14
|
setErrorHandlerCalls: 0,
|
|
15
15
|
errorHandler: null,
|
|
16
16
|
contentTypeParsers: new Map(),
|
|
17
|
+
hooks: {},
|
|
17
18
|
route(definition) {
|
|
18
19
|
routes.push(definition);
|
|
19
20
|
},
|
|
21
|
+
addHook(name, handler) {
|
|
22
|
+
this.hooks[name] = handler;
|
|
23
|
+
},
|
|
20
24
|
setErrorHandler(handler) {
|
|
21
25
|
this.errorHandler = handler;
|
|
22
26
|
this.setErrorHandlerCalls += 1;
|
|
@@ -5,6 +5,7 @@ import { resolveDefaultSurfaceId } from "../support/appConfig.js";
|
|
|
5
5
|
|
|
6
6
|
const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
|
|
7
7
|
const JSON_API_CONTENT_TYPE_PARSER_MARKER = Symbol.for("jskit.fastify.jsonApiContentTypeParserRegistered");
|
|
8
|
+
const BODYLESS_CONTENT_TYPE_NORMALIZER_MARKER = Symbol.for("jskit.fastify.bodylessContentTypeNormalizerRegistered");
|
|
8
9
|
|
|
9
10
|
function resolveLoggerLevel({ configuredLevel = "", nodeEnv = "development", allowedLevels = [] } = {}) {
|
|
10
11
|
const normalizedConfiguredLevel = String(configuredLevel || "")
|
|
@@ -83,6 +84,64 @@ function registerJsonApiContentTypeParser(fastify) {
|
|
|
83
84
|
return true;
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
function hasHeaderValue(headers = {}, key = "") {
|
|
88
|
+
return String(headers?.[key] ?? "").trim().length > 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function hasRequestBody(headers = {}) {
|
|
92
|
+
const transferEncoding = String(headers?.["transfer-encoding"] ?? "").trim();
|
|
93
|
+
if (transferEncoding) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const contentLengthHeader = String(headers?.["content-length"] ?? "").trim();
|
|
98
|
+
if (!contentLengthHeader) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const contentLength = Number(contentLengthHeader);
|
|
103
|
+
if (!Number.isFinite(contentLength)) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return contentLength > 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeBodylessContentTypeHeader(request = null) {
|
|
111
|
+
const headers = request?.headers;
|
|
112
|
+
if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!hasHeaderValue(headers, "content-type") && !hasHeaderValue(headers, "Content-Type")) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (hasRequestBody(headers)) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
delete headers["content-type"];
|
|
125
|
+
delete headers["Content-Type"];
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function registerBodylessContentTypeNormalizer(fastify) {
|
|
130
|
+
if (!fastify || typeof fastify.addHook !== "function") {
|
|
131
|
+
throw new TypeError("registerBodylessContentTypeNormalizer requires a Fastify instance.");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (fastify[BODYLESS_CONTENT_TYPE_NORMALIZER_MARKER]) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
fastify.addHook("onRequest", async (request) => {
|
|
139
|
+
normalizeBodylessContentTypeHeader(request);
|
|
140
|
+
});
|
|
141
|
+
fastify[BODYLESS_CONTENT_TYPE_NORMALIZER_MARKER] = true;
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
86
145
|
function registerRequestLoggingHooks(
|
|
87
146
|
app,
|
|
88
147
|
{
|
|
@@ -483,6 +542,8 @@ export {
|
|
|
483
542
|
resolveLoggerLevel,
|
|
484
543
|
createFastifyLoggerOptions,
|
|
485
544
|
registerJsonApiContentTypeParser,
|
|
545
|
+
normalizeBodylessContentTypeHeader,
|
|
546
|
+
registerBodylessContentTypeNormalizer,
|
|
486
547
|
registerRequestLoggingHooks,
|
|
487
548
|
registerApiErrorHandler,
|
|
488
549
|
ensureApiErrorHandling,
|
|
@@ -2,7 +2,13 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
|
|
4
4
|
import { AppError, isAppError } from "./errors.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
normalizeBodylessContentTypeHeader,
|
|
7
|
+
registerApiErrorHandler,
|
|
8
|
+
registerBodylessContentTypeNormalizer,
|
|
9
|
+
registerJsonApiContentTypeParser,
|
|
10
|
+
registerRequestLoggingHooks
|
|
11
|
+
} from "./fastifyBootstrap.js";
|
|
6
12
|
|
|
7
13
|
function createFastifyStub() {
|
|
8
14
|
return {
|
|
@@ -275,6 +281,46 @@ test("registerJsonApiContentTypeParser installs the JSON:API media type parser o
|
|
|
275
281
|
assert.equal(fastify.contentTypeParsers.size, 1);
|
|
276
282
|
});
|
|
277
283
|
|
|
284
|
+
test("normalizeBodylessContentTypeHeader strips meaningless content type from empty requests", () => {
|
|
285
|
+
const request = {
|
|
286
|
+
headers: {
|
|
287
|
+
"content-type": "text/plain;charset=UTF-8"
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
assert.equal(normalizeBodylessContentTypeHeader(request), true);
|
|
292
|
+
assert.deepEqual(request.headers, {});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("normalizeBodylessContentTypeHeader keeps content type when a body is declared", () => {
|
|
296
|
+
const request = {
|
|
297
|
+
headers: {
|
|
298
|
+
"content-type": "text/plain;charset=UTF-8",
|
|
299
|
+
"content-length": "12"
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
assert.equal(normalizeBodylessContentTypeHeader(request), false);
|
|
304
|
+
assert.equal(request.headers["content-type"], "text/plain;charset=UTF-8");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("registerBodylessContentTypeNormalizer installs the request hook once", async () => {
|
|
308
|
+
const fastify = createFastifyStub();
|
|
309
|
+
assert.equal(registerBodylessContentTypeNormalizer(fastify), true);
|
|
310
|
+
assert.equal(registerBodylessContentTypeNormalizer(fastify), false);
|
|
311
|
+
|
|
312
|
+
const request = {
|
|
313
|
+
headers: {
|
|
314
|
+
"content-type": "text/plain;charset=UTF-8",
|
|
315
|
+
"content-length": "0"
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
await fastify.hooks.onRequest(request);
|
|
319
|
+
assert.deepEqual(request.headers, {
|
|
320
|
+
"content-length": "0"
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
278
324
|
test("registerRequestLoggingHooks uses configured default surface when getSurface is absent", async () => {
|
|
279
325
|
const fastify = createFastifyStub();
|
|
280
326
|
let loggedPayload = null;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { fileURLToPath
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { fileExists } from "../../internal/node/fileSystem.js";
|
|
4
|
+
import { importFreshModuleFromAbsolutePath } from "./importFreshModuleFromAbsolutePath.js";
|
|
4
5
|
|
|
5
6
|
const PUBLIC_CONFIG_RELATIVE_PATH = "config/public.js";
|
|
6
7
|
const SERVER_CONFIG_RELATIVE_PATH = "config/server.js";
|
|
@@ -35,7 +36,7 @@ async function loadConfigModuleAtPath(absolutePath) {
|
|
|
35
36
|
return {};
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
const loadedModule = await
|
|
39
|
+
const loadedModule = await importFreshModuleFromAbsolutePath(absolutePath);
|
|
39
40
|
return normalizeConfigObject(loadedModule?.config);
|
|
40
41
|
}
|
|
41
42
|
|
|
@@ -47,6 +47,21 @@ test("loadAppConfigFromAppRoot merges public and server config", async () => {
|
|
|
47
47
|
assert.equal(Object.isFrozen(loaded), true);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
test("loadAppConfigFromAppRoot re-reads config changes within the same process", async () => {
|
|
51
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "kernel-app-config-"));
|
|
52
|
+
const appRoot = path.join(tempRoot, "app");
|
|
53
|
+
const publicConfigPath = path.join(appRoot, "config", "public.js");
|
|
54
|
+
await mkdir(path.dirname(publicConfigPath), { recursive: true });
|
|
55
|
+
await writeFile(publicConfigPath, "export const config = { mobile: { enabled: false } };", "utf8");
|
|
56
|
+
|
|
57
|
+
const firstLoaded = await loadAppConfigFromAppRoot({ appRoot });
|
|
58
|
+
await writeFile(publicConfigPath, "export const config = { mobile: { enabled: true } };", "utf8");
|
|
59
|
+
const secondLoaded = await loadAppConfigFromAppRoot({ appRoot });
|
|
60
|
+
|
|
61
|
+
assert.equal(firstLoaded.mobile.enabled, false);
|
|
62
|
+
assert.equal(secondLoaded.mobile.enabled, true);
|
|
63
|
+
});
|
|
64
|
+
|
|
50
65
|
test("loadAppConfigFromAppRoot requires an explicit appRoot", async () => {
|
|
51
66
|
await assert.rejects(
|
|
52
67
|
loadAppConfigFromAppRoot({ appRoot: "" }),
|
package/server/support/index.js
CHANGED
|
@@ -373,8 +373,8 @@ function buildAncestorRouteContexts(pageTarget = {}) {
|
|
|
373
373
|
|
|
374
374
|
for (let visiblePrefixLength = visibleRouteSegments.length - 1; visiblePrefixLength >= 1; visiblePrefixLength -= 1) {
|
|
375
375
|
const parentVisibleSegments = visibleRouteSegments.slice(0, visiblePrefixLength);
|
|
376
|
-
|
|
377
|
-
|
|
376
|
+
const actualRouteSegments = [];
|
|
377
|
+
let collectedVisibleSegments = 0;
|
|
378
378
|
|
|
379
379
|
for (const segment of routeSegments) {
|
|
380
380
|
actualRouteSegments.push(segment);
|
|
@@ -585,7 +585,8 @@ function topologyPlacementTargetsConcreteOutlet(placement = {}, target = "") {
|
|
|
585
585
|
async function resolveSemanticPlacementTargetForConcreteOutlet({
|
|
586
586
|
appRoot,
|
|
587
587
|
concreteTarget = "",
|
|
588
|
-
surface = ""
|
|
588
|
+
surface = "",
|
|
589
|
+
context = "page target"
|
|
589
590
|
} = {}) {
|
|
590
591
|
const normalizedConcreteTarget = normalizeShellOutletTargetId(concreteTarget);
|
|
591
592
|
if (!normalizedConcreteTarget) {
|
|
@@ -595,16 +596,31 @@ async function resolveSemanticPlacementTargetForConcreteOutlet({
|
|
|
595
596
|
const topology = await discoverPlacementTopologyFromApp({ appRoot });
|
|
596
597
|
const owner = resolveConcreteTargetOwner(normalizedConcreteTarget);
|
|
597
598
|
const normalizedSurface = normalizeSurfaceId(surface);
|
|
598
|
-
|
|
599
|
+
const candidates = (Array.isArray(topology.placements) ? topology.placements : []).filter((placement) => {
|
|
599
600
|
if (!topologyPlacementTargetsConcreteOutlet(placement, normalizedConcreteTarget)) {
|
|
600
601
|
return false;
|
|
601
602
|
}
|
|
602
|
-
if (placement.owner && placement.owner !== owner) {
|
|
603
|
-
return false;
|
|
604
|
-
}
|
|
605
603
|
const surfaces = Array.isArray(placement.surfaces) ? placement.surfaces : ["*"];
|
|
606
604
|
return !normalizedSurface || surfaces.includes("*") || surfaces.includes(normalizedSurface);
|
|
607
|
-
})
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
const exactOwnerMatches = candidates.filter((placement) => placement.owner && placement.owner === owner);
|
|
608
|
+
const globalMatches = candidates.filter((placement) => !placement.owner);
|
|
609
|
+
const selectedMatches = exactOwnerMatches.length > 0 ? exactOwnerMatches : globalMatches;
|
|
610
|
+
if (selectedMatches.length < 1) {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
if (selectedMatches.length === 1) {
|
|
614
|
+
return selectedMatches[0];
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const targetLabels = selectedMatches.map((placement) =>
|
|
618
|
+
placement.owner ? `${placement.id} [owner:${placement.owner}]` : placement.id
|
|
619
|
+
);
|
|
620
|
+
throw new Error(
|
|
621
|
+
`${context} found multiple semantic placements mapped to concrete outlet "${normalizedConcreteTarget}": ` +
|
|
622
|
+
`${targetLabels.join(", ")}. Pass --link-placement or make the topology mapping unambiguous.`
|
|
623
|
+
);
|
|
608
624
|
}
|
|
609
625
|
|
|
610
626
|
function resolveRelativeLinkToFromParent(pageTarget = {}, parentHost = null) {
|
|
@@ -388,6 +388,149 @@ test("resolvePageLinkTargetDetails prefers an outlet-declared default link token
|
|
|
388
388
|
});
|
|
389
389
|
});
|
|
390
390
|
|
|
391
|
+
test("resolvePageLinkTargetDetails infers owner-scoped placement for sibling file-route children", async () => {
|
|
392
|
+
await withTempApp(async (appRoot) => {
|
|
393
|
+
await writeConfig(
|
|
394
|
+
appRoot,
|
|
395
|
+
`export const config = {
|
|
396
|
+
surfaceDefinitions: {
|
|
397
|
+
home: { id: "home", pagesRoot: "home", enabled: true }
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
`
|
|
401
|
+
);
|
|
402
|
+
await writePlacementTopology(appRoot, [
|
|
403
|
+
renderTopologyEntry({
|
|
404
|
+
id: "page.section-nav",
|
|
405
|
+
owner: "home-settings",
|
|
406
|
+
surfaces: ["home"],
|
|
407
|
+
outlet: "home-settings:primary-menu",
|
|
408
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
409
|
+
})
|
|
410
|
+
]);
|
|
411
|
+
await writeFileInApp(
|
|
412
|
+
appRoot,
|
|
413
|
+
"src/pages/home/settings.vue",
|
|
414
|
+
`<template>
|
|
415
|
+
<section>
|
|
416
|
+
<ShellOutlet target="home-settings:primary-menu" />
|
|
417
|
+
<RouterView />
|
|
418
|
+
</section>
|
|
419
|
+
</template>
|
|
420
|
+
`
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const details = await resolvePageLinkTargetDetails({
|
|
424
|
+
appRoot,
|
|
425
|
+
targetFile: "home/settings/profile.vue",
|
|
426
|
+
context: "page target"
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
assert.equal(details.parentHost?.id, "home-settings:primary-menu");
|
|
430
|
+
assert.equal(details.placementTarget.id, "page.section-nav");
|
|
431
|
+
assert.equal(details.placementTarget.owner, "home-settings");
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("resolvePageLinkTargetDetails prefers owner-scoped topology over a global mapping for the same concrete outlet", async () => {
|
|
436
|
+
await withTempApp(async (appRoot) => {
|
|
437
|
+
await writeConfig(
|
|
438
|
+
appRoot,
|
|
439
|
+
`export const config = {
|
|
440
|
+
surfaceDefinitions: {
|
|
441
|
+
home: { id: "home", pagesRoot: "home", enabled: true }
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
`
|
|
445
|
+
);
|
|
446
|
+
await writePlacementTopology(appRoot, [
|
|
447
|
+
renderTopologyEntry({
|
|
448
|
+
id: "page.section-nav",
|
|
449
|
+
surfaces: ["home"],
|
|
450
|
+
outlet: "home-settings:primary-menu",
|
|
451
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
452
|
+
}),
|
|
453
|
+
renderTopologyEntry({
|
|
454
|
+
id: "page.section-nav",
|
|
455
|
+
owner: "home-settings",
|
|
456
|
+
surfaces: ["home"],
|
|
457
|
+
outlet: "home-settings:primary-menu",
|
|
458
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
459
|
+
})
|
|
460
|
+
]);
|
|
461
|
+
await writeFileInApp(
|
|
462
|
+
appRoot,
|
|
463
|
+
"src/pages/home/settings.vue",
|
|
464
|
+
`<template>
|
|
465
|
+
<section>
|
|
466
|
+
<ShellOutlet target="home-settings:primary-menu" />
|
|
467
|
+
<RouterView />
|
|
468
|
+
</section>
|
|
469
|
+
</template>
|
|
470
|
+
`
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
const details = await resolvePageLinkTargetDetails({
|
|
474
|
+
appRoot,
|
|
475
|
+
targetFile: "home/settings/profile.vue",
|
|
476
|
+
context: "page target"
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
assert.equal(details.placementTarget.id, "page.section-nav");
|
|
480
|
+
assert.equal(details.placementTarget.owner, "home-settings");
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("resolvePageLinkTargetDetails rejects ambiguous semantic mappings for the same owner outlet", async () => {
|
|
485
|
+
await withTempApp(async (appRoot) => {
|
|
486
|
+
await writeConfig(
|
|
487
|
+
appRoot,
|
|
488
|
+
`export const config = {
|
|
489
|
+
surfaceDefinitions: {
|
|
490
|
+
home: { id: "home", pagesRoot: "home", enabled: true }
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
`
|
|
494
|
+
);
|
|
495
|
+
await writePlacementTopology(appRoot, [
|
|
496
|
+
renderTopologyEntry({
|
|
497
|
+
id: "page.section-nav",
|
|
498
|
+
owner: "home-settings",
|
|
499
|
+
surfaces: ["home"],
|
|
500
|
+
outlet: "home-settings:primary-menu",
|
|
501
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
502
|
+
}),
|
|
503
|
+
renderTopologyEntry({
|
|
504
|
+
id: "page.actions",
|
|
505
|
+
owner: "home-settings",
|
|
506
|
+
surfaces: ["home"],
|
|
507
|
+
outlet: "home-settings:primary-menu",
|
|
508
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
509
|
+
})
|
|
510
|
+
]);
|
|
511
|
+
await writeFileInApp(
|
|
512
|
+
appRoot,
|
|
513
|
+
"src/pages/home/settings.vue",
|
|
514
|
+
`<template>
|
|
515
|
+
<section>
|
|
516
|
+
<ShellOutlet target="home-settings:primary-menu" />
|
|
517
|
+
<RouterView />
|
|
518
|
+
</section>
|
|
519
|
+
</template>
|
|
520
|
+
`
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
await assert.rejects(
|
|
524
|
+
resolvePageLinkTargetDetails({
|
|
525
|
+
appRoot,
|
|
526
|
+
targetFile: "home/settings/profile.vue",
|
|
527
|
+
context: "page target"
|
|
528
|
+
}),
|
|
529
|
+
/found multiple semantic placements mapped to concrete outlet "home-settings:primary-menu": page\.actions \[owner:home-settings\], page\.section-nav \[owner:home-settings\]/
|
|
530
|
+
);
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
391
534
|
test("resolvePageLinkTargetDetails inherits a file-route parent subpages host", async () => {
|
|
392
535
|
await withTempApp(async (appRoot) => {
|
|
393
536
|
await writeConfig(
|