@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/kernel",
3
- "version": "0.1.33",
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
+ });
@@ -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 = "users.web.shell.surface-aware-menu-link-item";
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 be relative to src/pages/, without the src/pages/ prefix: ${normalizedValue}.`
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/, without a leading src/ segment: ${normalizedValue}.`
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
- moduleNamespace = await import(pathToFileURL(configPath).href);
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
- const config = normalizeObject(
195
- moduleNamespace?.config ||
196
- moduleNamespace?.default?.config ||
197
- moduleNamespace?.default
198
- );
199
- if (Object.keys(config).length < 1) {
200
- throw new Error(`${context} requires exported config in config/public.js.`);
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 config;
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
- const host = normalizeText(target?.host);
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 host="shell-layout" position="top-right" />
40
- <ShellOutlet host="shell-layout" position="primary-menu" default />
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 rejects target files with a src/pages prefix", async () => {
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 assert.rejects(
169
- () =>
170
- resolvePageTargetDetails({
171
- appRoot,
172
- targetFile: "src/pages/admin/reports/index.vue",
173
- context: "page target"
174
- }),
175
- /must be relative to src\/pages\/, without the src\/pages\/ prefix/
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 rejects route roots with a src/pages prefix", () => {
181
- assert.throws(
182
- () =>
183
- normalizePagesRelativeTargetRoot("src/pages/admin/customers", {
184
- context: "crud-ui-generator",
185
- label: 'option "target-root"'
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.host, "shell-layout");
212
- assert.equal(details.placementTarget.position, "primary-menu");
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 host="contact-view" position="sub-pages" />
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.host, "contact-view");
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.host, "shell-layout");
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 host="customer-view" position="sub-pages" />
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.host, "customer-view");
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 outletRecord = normalizeObject(outlet);
56
- const outletTargetId = normalizeShellOutletTargetId(
57
- `${normalizeText(outletRecord.host)}:${normalizeText(outletRecord.position)}`
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
- id: outletTargetId,
68
- host,
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 outletRecord = normalizeObject(outlet);
205
- const outletTargetId = normalizeShellOutletTargetId(
206
- `${normalizeText(outletRecord.host)}:${normalizeText(outletRecord.position)}`
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 separatorIndex = outletTargetId.indexOf(":");
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
- id: outletTargetId,
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
- Object.freeze({
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 host="shell-layout" position="primary-menu" default />) ` +
384
- `or pass "--placement host:position". Available targets: ${availableTargets || "<none>"}.`
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 host="shell-layout" position="primary-menu" />
34
- <ShellOutlet host="shell-layout" position="top-right" />
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 host="admin-settings" position="forms" default />
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.host, "admin-settings");
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 host="shell-layout" position="primary-menu" default />
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
- { host: "workspace-tools", position: "primary-menu", source: "src/client/components/UsersWorkspaceToolsWidget.vue" }
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 host="shell-layout" position="primary-menu" />
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 host="admin-toolbox" position="widgets" default />
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
- "host": "contact-tools",
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 host="shell-layout" position="primary-menu" default />
231
- <ShellOutlet host="shell-layout" position="top-right" />
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.host, "shell-layout");
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 host="shell-layout" position="primary-menu" default />
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 host="shell-layout" position="primary-menu" default />
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 host="admin-settings" position="forms" default />
349
+ <ShellOutlet target="admin-settings:forms" default />
291
350
  </section>
292
351
  </template>
293
352
  `
@@ -1,4 +1,7 @@
1
- import { normalizeText } from "./normalize.js";
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 host = normalizeText(attributes.host);
82
- const position = normalizeText(attributes.position);
83
- if (!host || !position) {
135
+ const normalizedTarget = normalizeShellOutletTargetRecord(attributes, {
136
+ context: resolvedContext
137
+ });
138
+ if (!normalizedTarget) {
84
139
  continue;
85
140
  }
86
-
87
- const id = normalizeShellOutletTargetId(`${host}:${position}`);
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
- const hasDefaultAttribute = Object.hasOwn(attributes, "default") && isDefaultAttributeEnabled(attributes.default);
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 host="shell-layout" position="top-left" />
22
- <ShellOutlet host="shell-layout" position="primary-menu" default />
23
- <ShellOutlet host="shell-layout" position="secondary-menu" />
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 host="shell-layout" position="top-right" />
50
- <ShellOutlet host="shell-layout" position="top-right" />
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 host="shell-layout" position="primary-menu" default />
64
- <ShellOutlet host="shell-layout" position="secondary-menu" default />
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 host="shell-layout" position="primary-menu" default="false" />
78
- <ShellOutlet host="shell-layout" position="secondary-menu" />
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