@jskit-ai/kernel 0.1.65 → 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.
@@ -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
 
@@ -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.65",
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 { registerApiErrorHandler, registerJsonApiContentTypeParser, registerRequestLoggingHooks } from "./fastifyBootstrap.js";
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, pathToFileURL } from "node:url";
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 import(pathToFileURL(absolutePath).href);
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: "" }),
@@ -16,6 +16,9 @@ export {
16
16
  resolvePageLinkTargetDetails
17
17
  } from "./pageTargets.js";
18
18
  export {
19
+ discoverPlacementTopologyFromApp,
20
+ discoverShellOutletSourcePathsFromApp,
19
21
  discoverShellOutletTargetsFromApp,
22
+ resolveSemanticPlacementTargetFromApp,
20
23
  resolveShellOutletPlacementTargetFromApp
21
24
  } from "./shellOutlets.js";
@@ -14,7 +14,10 @@ import {
14
14
  findShellOutletTargetById,
15
15
  normalizeShellOutletTargetId
16
16
  } from "../../shared/support/shellLayoutTargets.js";
17
- import { resolveShellOutletPlacementTargetFromApp } from "./shellOutlets.js";
17
+ import {
18
+ discoverPlacementTopologyFromApp,
19
+ resolveSemanticPlacementTargetFromApp
20
+ } from "./shellOutlets.js";
18
21
  import { resolveRequiredAppRoot, toPosixPath } from "./path.js";
19
22
  import { loadAppConfigFromModuleUrl } from "./appConfigFiles.js";
20
23
 
@@ -370,8 +373,8 @@ function buildAncestorRouteContexts(pageTarget = {}) {
370
373
 
371
374
  for (let visiblePrefixLength = visibleRouteSegments.length - 1; visiblePrefixLength >= 1; visiblePrefixLength -= 1) {
372
375
  const parentVisibleSegments = visibleRouteSegments.slice(0, visiblePrefixLength);
373
- const actualRouteSegments = [];
374
- let collectedVisibleSegments = 0;
376
+ const actualRouteSegments = [];
377
+ let collectedVisibleSegments = 0;
375
378
 
376
379
  for (const segment of routeSegments) {
377
380
  actualRouteSegments.push(segment);
@@ -562,6 +565,64 @@ function normalizePlacementTargetId(target = {}) {
562
565
  return normalizeShellOutletTargetId(target?.id || target?.target || target);
563
566
  }
564
567
 
568
+ function resolveConcreteTargetOwner(target = "") {
569
+ const normalizedTarget = normalizeShellOutletTargetId(target);
570
+ if (!normalizedTarget) {
571
+ return "";
572
+ }
573
+ return normalizedTarget.slice(0, normalizedTarget.indexOf(":"));
574
+ }
575
+
576
+ function topologyPlacementTargetsConcreteOutlet(placement = {}, target = "") {
577
+ const normalizedTarget = normalizeShellOutletTargetId(target);
578
+ if (!normalizedTarget) {
579
+ return false;
580
+ }
581
+ const variants = placement?.variants && typeof placement.variants === "object" ? placement.variants : {};
582
+ return Object.values(variants).some((variant) => normalizeShellOutletTargetId(variant?.outlet) === normalizedTarget);
583
+ }
584
+
585
+ async function resolveSemanticPlacementTargetForConcreteOutlet({
586
+ appRoot,
587
+ concreteTarget = "",
588
+ surface = "",
589
+ context = "page target"
590
+ } = {}) {
591
+ const normalizedConcreteTarget = normalizeShellOutletTargetId(concreteTarget);
592
+ if (!normalizedConcreteTarget) {
593
+ return null;
594
+ }
595
+
596
+ const topology = await discoverPlacementTopologyFromApp({ appRoot });
597
+ const owner = resolveConcreteTargetOwner(normalizedConcreteTarget);
598
+ const normalizedSurface = normalizeSurfaceId(surface);
599
+ const candidates = (Array.isArray(topology.placements) ? topology.placements : []).filter((placement) => {
600
+ if (!topologyPlacementTargetsConcreteOutlet(placement, normalizedConcreteTarget)) {
601
+ return false;
602
+ }
603
+ const surfaces = Array.isArray(placement.surfaces) ? placement.surfaces : ["*"];
604
+ return !normalizedSurface || surfaces.includes("*") || surfaces.includes(normalizedSurface);
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
+ );
624
+ }
625
+
565
626
  function resolveRelativeLinkToFromParent(pageTarget = {}, parentHost = null) {
566
627
  const childSegments = Array.isArray(pageTarget?.visibleRouteSegments) ? pageTarget.visibleRouteSegments : [];
567
628
  const parentSegments = Array.isArray(parentHost?.visibleRouteSegments) ? parentHost.visibleRouteSegments : [];
@@ -598,7 +659,7 @@ function resolveInferredPageLinkTo({
598
659
  explicitLinkTo = "",
599
660
  pageTarget = {},
600
661
  parentHost = null,
601
- placementTarget = null,
662
+ preservesRelativeSubpageLinks = false,
602
663
  suppressImplicitRelativeLinks = false
603
664
  } = {}) {
604
665
  const normalizedExplicitLinkTo = normalizeText(explicitLinkTo);
@@ -609,14 +670,10 @@ function resolveInferredPageLinkTo({
609
670
  return "";
610
671
  }
611
672
 
612
- const parentTargetId = normalizePlacementTargetId(parentHost);
613
- const placementTargetId = normalizePlacementTargetId(placementTarget);
614
- if (parentTargetId && parentTargetId === placementTargetId) {
615
- if (parentHost?.isSectionSubpagesHost === true) {
616
- const inferredLinkTo = resolveRelativeLinkToFromParent(pageTarget, parentHost);
617
- if (inferredLinkTo) {
618
- return inferredLinkTo;
619
- }
673
+ if (preservesRelativeSubpageLinks === true && parentHost?.isSectionSubpagesHost === true) {
674
+ const inferredLinkTo = resolveRelativeLinkToFromParent(pageTarget, parentHost);
675
+ if (inferredLinkTo) {
676
+ return inferredLinkTo;
620
677
  }
621
678
  }
622
679
 
@@ -627,36 +684,6 @@ function resolveInferredPageLinkTo({
627
684
  return "";
628
685
  }
629
686
 
630
- function resolveInferredPageLinkComponentToken({
631
- explicitComponentToken = "",
632
- parentHost = null,
633
- placementTarget = null,
634
- defaultComponentToken = DEFAULT_PAGE_LINK_COMPONENT_TOKEN,
635
- subpageComponentToken = DEFAULT_SUBPAGE_LINK_COMPONENT_TOKEN
636
- } = {}) {
637
- const normalizedExplicitToken = normalizeText(explicitComponentToken);
638
- if (normalizedExplicitToken) {
639
- return normalizedExplicitToken;
640
- }
641
-
642
- const normalizedPlacementTargetDefaultToken = normalizeText(placementTarget?.defaultLinkComponentToken);
643
- if (normalizedPlacementTargetDefaultToken) {
644
- return normalizedPlacementTargetDefaultToken;
645
- }
646
-
647
- const parentTargetId = normalizePlacementTargetId(parentHost);
648
- const placementTargetId = normalizePlacementTargetId(placementTarget);
649
- if (
650
- parentHost?.isSectionSubpagesHost === true &&
651
- parentTargetId &&
652
- parentTargetId === placementTargetId
653
- ) {
654
- return normalizeText(subpageComponentToken) || DEFAULT_SUBPAGE_LINK_COMPONENT_TOKEN;
655
- }
656
-
657
- return normalizeText(defaultComponentToken) || DEFAULT_PAGE_LINK_COMPONENT_TOKEN;
658
- }
659
-
660
687
  function renderPageLinkWhenLine(pageTarget = {}) {
661
688
  if (pageTarget?.surfaceRequiresAuth !== true) {
662
689
  return "";
@@ -672,8 +699,6 @@ async function resolvePageLinkTargetDetails({
672
699
  placement = "",
673
700
  componentToken = "",
674
701
  linkTo = "",
675
- defaultComponentToken = DEFAULT_PAGE_LINK_COMPONENT_TOKEN,
676
- subpageComponentToken = DEFAULT_SUBPAGE_LINK_COMPONENT_TOKEN,
677
702
  context = "page target"
678
703
  } = {}) {
679
704
  const resolvedPageTarget = pageTarget || await resolvePageTargetDetails({
@@ -686,39 +711,49 @@ async function resolvePageLinkTargetDetails({
686
711
  pageTarget: resolvedPageTarget,
687
712
  context
688
713
  });
689
- const placementTarget = await resolveShellOutletPlacementTargetFromApp({
690
- appRoot: resolvedPageTarget.appRoot,
691
- context,
692
- placement: normalizeText(placement) || parentHost?.id || ""
693
- });
694
- const resolvedComponentToken = resolveInferredPageLinkComponentToken({
695
- explicitComponentToken: componentToken,
696
- parentHost,
697
- placementTarget,
698
- defaultComponentToken,
699
- subpageComponentToken
700
- });
701
714
  const parentTargetId = normalizePlacementTargetId(parentHost);
702
- const placementTargetId = normalizePlacementTargetId(placementTarget);
715
+ let placementTarget = null;
716
+ const explicitPlacement = normalizeText(placement);
717
+ if (explicitPlacement) {
718
+ placementTarget = await resolveSemanticPlacementTargetFromApp({
719
+ appRoot: resolvedPageTarget.appRoot,
720
+ context,
721
+ placement: explicitPlacement,
722
+ owner: parentTargetId ? resolveConcreteTargetOwner(parentTargetId) : "",
723
+ surface: resolvedPageTarget.surfaceId
724
+ });
725
+ } else if (parentTargetId) {
726
+ placementTarget = await resolveSemanticPlacementTargetForConcreteOutlet({
727
+ appRoot: resolvedPageTarget.appRoot,
728
+ concreteTarget: parentTargetId,
729
+ surface: resolvedPageTarget.surfaceId,
730
+ context
731
+ });
732
+ }
733
+ if (!placementTarget) {
734
+ placementTarget = await resolveSemanticPlacementTargetFromApp({
735
+ appRoot: resolvedPageTarget.appRoot,
736
+ context,
737
+ surface: resolvedPageTarget.surfaceId
738
+ });
739
+ }
703
740
  const preservesRelativeSubpageLinks =
704
741
  parentHost?.isSectionSubpagesHost === true &&
705
742
  Boolean(parentTargetId) &&
706
- parentTargetId === placementTargetId;
743
+ topologyPlacementTargetsConcreteOutlet(placementTarget, parentTargetId);
707
744
 
708
745
  return Object.freeze({
709
746
  pageTarget: resolvedPageTarget,
710
747
  parentHost,
711
748
  placementTarget,
712
- componentToken: resolvedComponentToken,
749
+ componentToken: normalizeText(componentToken),
713
750
  whenLine: renderPageLinkWhenLine(resolvedPageTarget),
714
751
  linkTo: resolveInferredPageLinkTo({
715
752
  explicitLinkTo: linkTo,
716
753
  pageTarget: resolvedPageTarget,
717
754
  parentHost,
718
- placementTarget,
719
- suppressImplicitRelativeLinks:
720
- resolvedComponentToken === (normalizeText(defaultComponentToken) || DEFAULT_PAGE_LINK_COMPONENT_TOKEN) &&
721
- preservesRelativeSubpageLinks !== true
755
+ preservesRelativeSubpageLinks,
756
+ suppressImplicitRelativeLinks: preservesRelativeSubpageLinks !== true
722
757
  })
723
758
  });
724
759
  }