@jskit-ai/shell-web 0.1.32 → 0.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.descriptor.mjs +111 -33
  2. package/package.json +8 -2
  3. package/src/client/components/ShellLayout.vue +11 -4
  4. package/src/client/components/ShellMenuLinkItem.vue +71 -0
  5. package/src/client/components/ShellOutlet.vue +10 -7
  6. package/src/client/components/ShellOutletMenuWidget.vue +57 -0
  7. package/src/client/components/ShellSurfaceAwareMenuLinkItem.vue +116 -0
  8. package/src/client/components/ShellTabLinkItem.vue +128 -0
  9. package/src/client/index.js +4 -0
  10. package/src/client/lib/menuIcons.js +210 -0
  11. package/src/client/placement/runtime.js +22 -22
  12. package/src/client/placement/validators.js +19 -49
  13. package/src/client/support/menuLinkTarget.js +97 -0
  14. package/src/server/support/localLinkItemScaffolds.js +80 -0
  15. package/templates/expected-existing/src/App.vue +13 -0
  16. package/templates/expected-existing/src/pages/home/index.vue +12 -0
  17. package/templates/expected-existing/src/pages/home.vue +13 -0
  18. package/templates/src/components/ShellLayout.vue +11 -4
  19. package/templates/src/components/menus/MenuLinkItem.vue +30 -0
  20. package/templates/src/components/menus/SurfaceAwareMenuLinkItem.vue +42 -0
  21. package/templates/src/components/menus/TabLinkItem.vue +34 -0
  22. package/templates/src/pages/home/settings/index.vue +8 -8
  23. package/templates/src/pages/home/settings.vue +4 -1
  24. package/test/bootstrapClaimContract.test.js +66 -0
  25. package/test/linkItemScaffoldContract.test.js +209 -0
  26. package/test/outletMenuWidgetContract.test.js +33 -0
  27. package/test/placementRegistry.test.js +17 -6
  28. package/test/placementRuntime.test.js +59 -44
  29. package/test/settingsPlacementContract.test.js +16 -5
  30. package/templates/src/pages/console/index.vue +0 -24
  31. package/templates/src/pages/console.vue +0 -20
@@ -0,0 +1,128 @@
1
+ <script setup>
2
+ import { computed } from "vue";
3
+ import { useRoute } from "vue-router";
4
+ import { resolveShellLinkPath } from "../navigation/linkResolver.js";
5
+ import {
6
+ resolveSurfaceIdFromPlacementPathname,
7
+ resolveSurfaceNavigationTargetFromPlacementContext,
8
+ useWebPlacementContext
9
+ } from "../placement/index.js";
10
+ import {
11
+ normalizeMenuLinkPathname,
12
+ resolveMenuLinkTarget
13
+ } from "../support/menuLinkTarget.js";
14
+
15
+ const props = defineProps({
16
+ label: {
17
+ type: String,
18
+ default: ""
19
+ },
20
+ to: {
21
+ type: String,
22
+ default: ""
23
+ },
24
+ surface: {
25
+ type: String,
26
+ default: ""
27
+ },
28
+ workspaceSuffix: {
29
+ type: String,
30
+ default: "/"
31
+ },
32
+ nonWorkspaceSuffix: {
33
+ type: String,
34
+ default: "/"
35
+ },
36
+ disabled: {
37
+ type: Boolean,
38
+ default: false
39
+ }
40
+ });
41
+
42
+ const route = useRoute();
43
+ const { context: placementContext } = useWebPlacementContext();
44
+
45
+ const currentSurfaceId = computed(() => {
46
+ return resolveSurfaceIdFromPlacementPathname(
47
+ placementContext.value,
48
+ String(route?.path || route?.fullPath || "").trim()
49
+ );
50
+ });
51
+
52
+ const resolvedTo = computed(() => {
53
+ return resolveMenuLinkTarget({
54
+ to: props.to,
55
+ surface: props.surface,
56
+ currentSurfaceId: currentSurfaceId.value,
57
+ placementContext: placementContext.value,
58
+ workspaceSuffix: props.workspaceSuffix,
59
+ nonWorkspaceSuffix: props.nonWorkspaceSuffix,
60
+ routeParams: route.params || {},
61
+ resolvePagePath(relativePath, options = {}) {
62
+ return resolveShellLinkPath({
63
+ context: placementContext.value,
64
+ surface: options.surface,
65
+ relativePath,
66
+ params: route.params || {},
67
+ strictParams: options.strictParams !== false
68
+ });
69
+ }
70
+ });
71
+ });
72
+
73
+ const resolvedTarget = computed(() => {
74
+ const target = String(resolvedTo.value || "").trim();
75
+ if (!target) {
76
+ return {
77
+ href: "",
78
+ sameOrigin: true
79
+ };
80
+ }
81
+
82
+ const navigationTarget = resolveSurfaceNavigationTargetFromPlacementContext(placementContext.value, {
83
+ path: target
84
+ });
85
+ return {
86
+ href: navigationTarget.href,
87
+ sameOrigin: navigationTarget.sameOrigin
88
+ };
89
+ });
90
+
91
+ const isActive = computed(() => {
92
+ if (!resolvedTarget.value.sameOrigin) {
93
+ return false;
94
+ }
95
+
96
+ const targetPath = normalizeMenuLinkPathname(resolvedTarget.value.href);
97
+ const currentPath = normalizeMenuLinkPathname(route.fullPath || route.path || "");
98
+ if (!targetPath || !currentPath) {
99
+ return false;
100
+ }
101
+
102
+ return currentPath === targetPath || currentPath.startsWith(`${targetPath}/`);
103
+ });
104
+ </script>
105
+
106
+ <template>
107
+ <v-btn
108
+ v-if="resolvedTarget.href"
109
+ class="tab-link-item"
110
+ variant="text"
111
+ size="small"
112
+ :to="resolvedTarget.sameOrigin ? resolvedTarget.href : undefined"
113
+ :href="resolvedTarget.sameOrigin ? undefined : resolvedTarget.href"
114
+ :active="isActive"
115
+ :disabled="disabled"
116
+ color="primary"
117
+ >
118
+ {{ label || "Tab" }}
119
+ </v-btn>
120
+ </template>
121
+
122
+ <style scoped>
123
+ .tab-link-item {
124
+ text-transform: none;
125
+ font-weight: 600;
126
+ border-radius: 999px;
127
+ }
128
+ </style>
@@ -8,7 +8,11 @@ export {
8
8
 
9
9
  export { default as ShellLayout } from "./components/ShellLayout.vue";
10
10
  export { default as ShellOutlet } from "./components/ShellOutlet.vue";
11
+ export { default as ShellOutletMenuWidget } from "./components/ShellOutletMenuWidget.vue";
11
12
  export { default as ShellErrorHost } from "./components/ShellErrorHost.vue";
13
+ export { default as ShellMenuLinkItem } from "./components/ShellMenuLinkItem.vue";
14
+ export { default as ShellSurfaceAwareMenuLinkItem } from "./components/ShellSurfaceAwareMenuLinkItem.vue";
15
+ export { default as ShellTabLinkItem } from "./components/ShellTabLinkItem.vue";
12
16
  export { useShellLayoutState } from "./composables/useShellLayoutState.js";
13
17
 
14
18
  const clientProviders = Object.freeze([ShellWebClientProvider]);
@@ -0,0 +1,210 @@
1
+ import * as mdiIcons from "@mdi/js";
2
+ import {
3
+ mdiAccountCircleOutline,
4
+ mdiAccountCogOutline,
5
+ mdiAccountGroupOutline,
6
+ mdiArrowRightCircleOutline,
7
+ mdiClipboardListOutline,
8
+ mdiCogOutline,
9
+ mdiConsoleNetworkOutline,
10
+ mdiFolderOutline,
11
+ mdiHomeVariantOutline,
12
+ mdiLogin,
13
+ mdiLogout,
14
+ mdiRobotOutline,
15
+ mdiShieldCrownOutline,
16
+ mdiViewDashboardOutline,
17
+ mdiViewListOutline
18
+ } from "@mdi/js";
19
+ import { isExternalLinkTarget, splitPathQueryHash } from "@jskit-ai/kernel/shared/support/linkPath";
20
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
21
+ import { normalizePathname as normalizeKernelPathname } from "@jskit-ai/kernel/shared/surface/paths";
22
+
23
+ const SURFACE_SWITCH_ICON_BY_ID = Object.freeze({
24
+ home: mdiHomeVariantOutline,
25
+ app: mdiViewDashboardOutline,
26
+ admin: mdiShieldCrownOutline,
27
+ console: mdiConsoleNetworkOutline
28
+ });
29
+
30
+ function resolveExplicitIconValue(explicitIcon = "") {
31
+ const normalizedExplicitIcon = normalizeText(explicitIcon);
32
+ if (!normalizedExplicitIcon) {
33
+ return "";
34
+ }
35
+
36
+ if (!normalizedExplicitIcon.startsWith("mdi-")) {
37
+ return normalizedExplicitIcon;
38
+ }
39
+
40
+ const iconKey = normalizedExplicitIcon
41
+ .slice("mdi-".length)
42
+ .split("-")
43
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
44
+ .join("");
45
+ const exportName = `mdi${iconKey}`;
46
+ const resolvedIcon = mdiIcons[exportName];
47
+ return typeof resolvedIcon === "string" && resolvedIcon ? resolvedIcon : normalizedExplicitIcon;
48
+ }
49
+
50
+ function normalizePathname(value = "") {
51
+ const normalizedValue = normalizeText(value);
52
+ if (!normalizedValue) {
53
+ return "";
54
+ }
55
+
56
+ if (isExternalLinkTarget(normalizedValue)) {
57
+ const isHttpTarget = normalizedValue.startsWith("http://") || normalizedValue.startsWith("https://");
58
+ if (!isHttpTarget) {
59
+ return "";
60
+ }
61
+
62
+ let parsedPathname = "";
63
+ try {
64
+ parsedPathname = String(new URL(normalizedValue).pathname || "");
65
+ } catch {
66
+ return "";
67
+ }
68
+
69
+ const normalizedPathname = normalizeText(parsedPathname);
70
+ if (!normalizedPathname) {
71
+ return "";
72
+ }
73
+ return normalizeKernelPathname(normalizedPathname).toLowerCase();
74
+ }
75
+
76
+ const { pathname } = splitPathQueryHash(normalizedValue);
77
+ const normalizedPathname = normalizeText(pathname);
78
+ if (!normalizedPathname) {
79
+ return "";
80
+ }
81
+
82
+ return normalizeKernelPathname(normalizedPathname).toLowerCase();
83
+ }
84
+
85
+ function resolveSurfaceSwitchIdFromLabel(label = "") {
86
+ const normalizedLabel = normalizeText(label).toLowerCase();
87
+ if (!normalizedLabel.startsWith("go to ")) {
88
+ return "";
89
+ }
90
+ return normalizeText(normalizedLabel.slice("go to ".length));
91
+ }
92
+
93
+ function resolveSurfaceSwitchIcon(surfaceId = "", explicitIcon = "") {
94
+ const resolvedExplicitIcon = resolveExplicitIconValue(explicitIcon);
95
+ if (resolvedExplicitIcon) {
96
+ return resolvedExplicitIcon;
97
+ }
98
+
99
+ const normalizedSurfaceId = normalizeText(surfaceId).toLowerCase();
100
+ return SURFACE_SWITCH_ICON_BY_ID[normalizedSurfaceId] || mdiArrowRightCircleOutline;
101
+ }
102
+
103
+ function resolveMenuLinkIcon({ icon = "", label = "", to = "" } = {}) {
104
+ const resolvedExplicitIcon = resolveExplicitIconValue(icon);
105
+ if (resolvedExplicitIcon) {
106
+ return resolvedExplicitIcon;
107
+ }
108
+
109
+ const normalizedLabel = normalizeText(label).toLowerCase();
110
+ const normalizedPathname = normalizePathname(to);
111
+ if (!normalizedLabel && !normalizedPathname) {
112
+ return "";
113
+ }
114
+
115
+ const surfaceSwitchSurfaceId = resolveSurfaceSwitchIdFromLabel(normalizedLabel);
116
+ if (surfaceSwitchSurfaceId) {
117
+ return resolveSurfaceSwitchIcon(surfaceSwitchSurfaceId);
118
+ }
119
+
120
+ if (
121
+ normalizedLabel.includes("sign in") ||
122
+ normalizedPathname.includes("/auth/login")
123
+ ) {
124
+ return mdiLogin;
125
+ }
126
+
127
+ if (
128
+ normalizedLabel.includes("sign out") ||
129
+ normalizedPathname.includes("/auth/signout")
130
+ ) {
131
+ return mdiLogout;
132
+ }
133
+
134
+ if (
135
+ normalizedLabel.includes("account") ||
136
+ normalizedPathname.includes("/account")
137
+ ) {
138
+ if (
139
+ normalizedLabel.includes("settings") ||
140
+ normalizedPathname.includes("/settings") ||
141
+ normalizedPathname === "/account"
142
+ ) {
143
+ return mdiAccountCogOutline;
144
+ }
145
+ return mdiAccountCircleOutline;
146
+ }
147
+
148
+ if (
149
+ normalizedLabel.includes("members") ||
150
+ normalizedLabel.includes("team") ||
151
+ normalizedPathname.includes("/members")
152
+ ) {
153
+ return mdiAccountGroupOutline;
154
+ }
155
+
156
+ if (normalizedLabel.includes("assistant") || normalizedPathname.includes("/assistant")) {
157
+ return mdiRobotOutline;
158
+ }
159
+
160
+ if (
161
+ normalizedLabel.includes("console") ||
162
+ normalizedPathname.startsWith("/console")
163
+ ) {
164
+ return mdiConsoleNetworkOutline;
165
+ }
166
+
167
+ if (
168
+ normalizedLabel.includes("admin") ||
169
+ normalizedPathname.includes("/admin")
170
+ ) {
171
+ return mdiShieldCrownOutline;
172
+ }
173
+
174
+ if (normalizedLabel.includes("settings") || normalizedPathname.includes("/settings")) {
175
+ return mdiCogOutline;
176
+ }
177
+
178
+ if (
179
+ normalizedLabel.includes("home") ||
180
+ normalizedPathname === "/"
181
+ ) {
182
+ return mdiHomeVariantOutline;
183
+ }
184
+
185
+ if (
186
+ normalizedLabel.includes("workspace") ||
187
+ normalizedLabel.includes("dashboard") ||
188
+ normalizedPathname.includes("/w/")
189
+ ) {
190
+ return mdiViewDashboardOutline;
191
+ }
192
+
193
+ if (normalizedPathname) {
194
+ const segments = normalizedPathname.split("/").filter(Boolean);
195
+ if (segments.length === 1) {
196
+ return mdiFolderOutline;
197
+ }
198
+ }
199
+
200
+ if (normalizedLabel.includes("list")) {
201
+ return mdiClipboardListOutline;
202
+ }
203
+
204
+ return mdiViewListOutline;
205
+ }
206
+
207
+ export {
208
+ resolveMenuLinkIcon,
209
+ resolveSurfaceSwitchIcon
210
+ };
@@ -4,8 +4,7 @@ import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
4
4
  import {
5
5
  isRenderableComponent,
6
6
  normalizePlacementDefinition,
7
- normalizePlacementHost,
8
- normalizePlacementPosition,
7
+ normalizePlacementTarget,
9
8
  normalizeSurface
10
9
  } from "./validators.js";
11
10
 
@@ -86,13 +85,19 @@ function normalizePlacementList(placements, context = {}) {
86
85
  byId.set(placement.id, placement);
87
86
  }
88
87
 
89
- return [...byId.values()].sort((left, right) => {
90
- const orderCompare = left.order - right.order;
91
- if (orderCompare !== 0) {
92
- return orderCompare;
93
- }
94
- return left.id.localeCompare(right.id);
95
- });
88
+ return [...byId.values()]
89
+ .map((placement, index) => ({
90
+ placement,
91
+ index
92
+ }))
93
+ .sort((left, right) => {
94
+ const orderCompare = left.placement.order - right.placement.order;
95
+ if (orderCompare !== 0) {
96
+ return orderCompare;
97
+ }
98
+ return left.index - right.index;
99
+ })
100
+ .map((entry) => entry.placement);
96
101
  }
97
102
 
98
103
  function matchesSurface(placementSurfaces, requestedSurface) {
@@ -303,10 +308,9 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
303
308
  return revision;
304
309
  }
305
310
 
306
- function getPlacements({ surface = WEB_PLACEMENT_SURFACE_ANY, host = "", position = "", context = {} } = {}) {
307
- const normalizedHost = normalizePlacementHost(host, { strict: false });
308
- const normalizedPosition = normalizePlacementPosition(position, { strict: false });
309
- if (!normalizedHost || !normalizedPosition) {
311
+ function getPlacements({ surface = WEB_PLACEMENT_SURFACE_ANY, target = "", context = {} } = {}) {
312
+ const normalizedTarget = normalizePlacementTarget(target, { strict: false });
313
+ if (!normalizedTarget) {
310
314
  return Object.freeze([]);
311
315
  }
312
316
 
@@ -318,8 +322,7 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
318
322
  {
319
323
  app,
320
324
  surface: normalizedSurface,
321
- host: normalizedHost,
322
- position: normalizedPosition,
325
+ target: normalizedTarget,
323
326
  context: {
324
327
  ...contextFromRuntime,
325
328
  ...baseContext
@@ -333,14 +336,12 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
333
336
  ...baseContext,
334
337
  app,
335
338
  surface: normalizedSurface,
336
- host: normalizedHost,
337
- position: normalizedPosition
339
+ target: normalizedTarget
338
340
  };
339
341
 
340
342
  debugLog("getPlacements:start", {
341
343
  surface: normalizedSurface,
342
- host: normalizedHost,
343
- position: normalizedPosition,
344
+ target: normalizedTarget,
344
345
  contextKeys: Object.keys(baseContext),
345
346
  sharedContextKeys: Object.keys(contextFromRuntime),
346
347
  placementCount: placementDefinitions.length
@@ -348,7 +349,7 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
348
349
 
349
350
  const matches = [];
350
351
  for (const placement of placementDefinitions) {
351
- if (placement.host !== normalizedHost || placement.position !== normalizedPosition) {
352
+ if (placement.target !== normalizedTarget) {
352
353
  continue;
353
354
  }
354
355
  const placementSurfaces = Array.isArray(placement.surfaces)
@@ -403,8 +404,7 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
403
404
 
404
405
  debugLog("getPlacements:done", {
405
406
  surface: normalizedSurface,
406
- host: normalizedHost,
407
- position: normalizedPosition,
407
+ target: normalizedTarget,
408
408
  resultIds: matches.map((entry) => entry.id)
409
409
  });
410
410
 
@@ -1,4 +1,5 @@
1
1
  import { isRecord, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { normalizeShellOutletTargetId } from "@jskit-ai/kernel/shared/support/shellLayoutTargets";
2
3
 
3
4
  const WEB_PLACEMENT_SURFACE_ANY = "*";
4
5
 
@@ -24,10 +25,6 @@ function isValidSurfaceIdToken(value = "") {
24
25
  return /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(value);
25
26
  }
26
27
 
27
- function isValidPlacementHostOrPositionToken(value = "") {
28
- return /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(value);
29
- }
30
-
31
28
  function normalizePlacementSurface(value, { strict = false, source = "placement" } = {}) {
32
29
  const normalized = normalizeText(value).toLowerCase();
33
30
  if (!normalized) {
@@ -59,40 +56,16 @@ function toInteger(value, fallback = 1000) {
59
56
  return Math.trunc(numeric);
60
57
  }
61
58
 
62
- function normalizePlacementHost(value, { strict = false, source = "placement" } = {}) {
63
- const normalized = normalizeText(value).toLowerCase();
64
- if (!normalized) {
65
- if (strict) {
66
- throw new TypeError(`${source} requires host.`);
67
- }
68
- return "";
69
- }
70
-
71
- if (!isValidPlacementHostOrPositionToken(normalized)) {
72
- if (strict) {
73
- throw new TypeError(`${source} host "${normalized}" is invalid.`);
74
- }
75
- return "";
76
- }
77
- return normalized;
78
- }
79
-
80
- function normalizePlacementPosition(value, { strict = false, source = "placement" } = {}) {
81
- const normalized = normalizeText(value).toLowerCase();
82
- if (!normalized) {
83
- if (strict) {
84
- throw new TypeError(`${source} requires position.`);
85
- }
86
- return "";
59
+ function normalizePlacementTarget(value, { strict = false, source = "placement" } = {}) {
60
+ const normalized = normalizeShellOutletTargetId(String(value || "").toLowerCase());
61
+ if (normalized) {
62
+ return normalized;
87
63
  }
88
64
 
89
- if (!isValidPlacementHostOrPositionToken(normalized)) {
90
- if (strict) {
91
- throw new TypeError(`${source} position "${normalized}" is invalid.`);
92
- }
93
- return "";
65
+ if (strict) {
66
+ throw new TypeError(`${source} requires target in "host:position" format.`);
94
67
  }
95
- return normalized;
68
+ return "";
96
69
  }
97
70
 
98
71
  function normalizePlacementSurfaces(value, { strict = false, source = "placement" } = {}) {
@@ -136,27 +109,26 @@ function normalizePlacementDefinition(value, { strict = false, source = "placeme
136
109
  return null;
137
110
  }
138
111
 
139
- const id = normalizeText(value.id);
140
- if (!id) {
112
+ if (Object.hasOwn(value, "host") || Object.hasOwn(value, "position")) {
141
113
  if (strict) {
142
- throw new TypeError(`${source} requires id.`);
114
+ throw new TypeError(`${source} must use "target" only. Legacy "host" and "position" fields are not supported.`);
143
115
  }
144
116
  return null;
145
117
  }
146
118
 
147
- const host = normalizePlacementHost(value.host, {
148
- strict,
149
- source: `${source} "${id}"`
150
- });
151
- if (!host) {
119
+ const id = normalizeText(value.id);
120
+ if (!id) {
121
+ if (strict) {
122
+ throw new TypeError(`${source} requires id.`);
123
+ }
152
124
  return null;
153
125
  }
154
126
 
155
- const position = normalizePlacementPosition(value.position, {
127
+ const target = normalizePlacementTarget(value.target, {
156
128
  strict,
157
129
  source: `${source} "${id}"`
158
130
  });
159
- if (!position) {
131
+ if (!target) {
160
132
  return null;
161
133
  }
162
134
 
@@ -177,8 +149,7 @@ function normalizePlacementDefinition(value, { strict = false, source = "placeme
177
149
 
178
150
  return Object.freeze({
179
151
  id,
180
- host,
181
- position,
152
+ target,
182
153
  surfaces,
183
154
  order: toInteger(value.order, 1000),
184
155
  componentToken,
@@ -200,8 +171,7 @@ export {
200
171
  isRenderableComponent,
201
172
  normalizeSurface,
202
173
  normalizePlacementSurface,
203
- normalizePlacementHost,
204
- normalizePlacementPosition,
174
+ normalizePlacementTarget,
205
175
  normalizePlacementSurfaces,
206
176
  normalizePlacementDefinition,
207
177
  definePlacement
@@ -0,0 +1,97 @@
1
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { resolveSurfaceDefinitionFromPlacementContext } from "../placement/surfaceContext.js";
3
+
4
+ function normalizeMenuLinkPathname(pathname = "") {
5
+ const source = String(pathname || "").trim();
6
+ if (!source) {
7
+ return "";
8
+ }
9
+
10
+ const queryIndex = source.indexOf("?");
11
+ const hashIndex = source.indexOf("#");
12
+ const cutoff =
13
+ queryIndex < 0
14
+ ? hashIndex
15
+ : hashIndex < 0
16
+ ? queryIndex
17
+ : Math.min(queryIndex, hashIndex);
18
+
19
+ return cutoff < 0 ? source : source.slice(0, cutoff);
20
+ }
21
+
22
+ function resolveMenuLinkSurfaceId(surface = "", fallbackSurfaceId = "") {
23
+ const explicitSurface = normalizeText(surface).toLowerCase();
24
+ if (explicitSurface && explicitSurface !== "*") {
25
+ return explicitSurface;
26
+ }
27
+
28
+ return normalizeText(fallbackSurfaceId).toLowerCase();
29
+ }
30
+
31
+ function interpolateBracketParams(pathTemplate = "", params = {}) {
32
+ const source = String(pathTemplate || "").trim();
33
+ if (!source) {
34
+ return "";
35
+ }
36
+
37
+ return source.replace(/\[([^\]]+)\]/g, (_match, rawKey) => {
38
+ const key = String(rawKey || "").trim();
39
+ if (!key) {
40
+ return "";
41
+ }
42
+
43
+ const value = params?.[key];
44
+ return value == null ? `[${key}]` : encodeURIComponent(String(value));
45
+ });
46
+ }
47
+
48
+ function isRelativeMenuLinkTarget(target = "") {
49
+ const normalizedTarget = normalizeText(target);
50
+ return normalizedTarget.startsWith("./") || normalizedTarget.startsWith("../");
51
+ }
52
+
53
+ function surfaceRequiresWorkspaceFromPlacementContext(contextValue = null, surfaceId = "") {
54
+ return Boolean(resolveSurfaceDefinitionFromPlacementContext(contextValue, surfaceId)?.requiresWorkspace);
55
+ }
56
+
57
+ function resolveMenuLinkTarget({
58
+ to = "",
59
+ surface = "",
60
+ currentSurfaceId = "",
61
+ placementContext = null,
62
+ workspaceSuffix = "/",
63
+ nonWorkspaceSuffix = "/",
64
+ routeParams = {},
65
+ resolvePagePath = null
66
+ } = {}) {
67
+ const explicitTarget = normalizeText(to);
68
+ const targetSurfaceId = resolveMenuLinkSurfaceId(surface, currentSurfaceId);
69
+ const workspaceRequired = surfaceRequiresWorkspaceFromPlacementContext(placementContext, targetSurfaceId);
70
+ const suffixTemplate = normalizeText(workspaceRequired ? workspaceSuffix : nonWorkspaceSuffix) || "/";
71
+ const interpolatedSuffix = interpolateBracketParams(suffixTemplate, routeParams);
72
+ const resolvedSuffixTarget =
73
+ typeof resolvePagePath === "function" &&
74
+ targetSurfaceId &&
75
+ interpolatedSuffix &&
76
+ !interpolatedSuffix.includes("[")
77
+ ? normalizeText(resolvePagePath(interpolatedSuffix, {
78
+ surface: targetSurfaceId,
79
+ mode: "auto"
80
+ }))
81
+ : "";
82
+
83
+ if (!explicitTarget) {
84
+ return resolvedSuffixTarget;
85
+ }
86
+
87
+ if (isRelativeMenuLinkTarget(explicitTarget)) {
88
+ return resolvedSuffixTarget;
89
+ }
90
+
91
+ return explicitTarget;
92
+ }
93
+
94
+ export {
95
+ normalizeMenuLinkPathname,
96
+ resolveMenuLinkTarget
97
+ };