@igstack/app-catalog-frontend-core 0.9.0 → 0.9.2

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.
@@ -1,7 +1,16 @@
1
1
  import { default as React } from 'react';
2
2
  /**
3
- * Renders a markdown link with security attributes (opens in a new tab,
4
- * no referrer/opener leakage). Shared so all catalog text fields render
3
+ * Renders a markdown link.
4
+ *
5
+ * Internal cross-references — a relative `/app/<slug>` pointing at another
6
+ * catalog entry — navigate WITHIN the app via the TanStack router (same tab,
7
+ * no full reload), so authors can cross-link entries with plain markdown
8
+ * `[Name](/app/<slug>)` (#25). The slug is validated against the loaded
9
+ * resources (canonical slug or a known alias); an unknown slug renders as
10
+ * plain text rather than a dead link.
11
+ *
12
+ * Everything else (external http/https links) opens in a new tab with
13
+ * `noopener noreferrer`, unchanged. Shared so all catalog text fields render
5
14
  * links identically.
6
15
  */
7
16
  export declare const MarkdownLink: ({ href, children, }: {
@@ -1,18 +1,50 @@
1
- import { jsx } from "react/jsx-runtime";
1
+ import { jsx, Fragment } from "react/jsx-runtime";
2
+ import { useRouterState, Link } from "@tanstack/react-router";
2
3
  import ReactMarkdown from "react-markdown";
4
+ import { useAppCatalogContext } from "../../context/AppCatalogContext.js";
5
+ const INTERNAL_APP_LINK = /^\/app\/([^/?#]+)$/;
3
6
  const MarkdownLink = ({
4
7
  href,
5
8
  children
6
- }) => /* @__PURE__ */ jsx(
7
- "a",
8
- {
9
- href,
10
- target: "_blank",
11
- rel: "noopener noreferrer",
12
- className: "text-primary hover:underline",
13
- children
9
+ }) => {
10
+ const { resources } = useAppCatalogContext();
11
+ const pathname = useRouterState({ select: (s) => s.location.pathname });
12
+ const internalMatch = href == null ? void 0 : href.match(INTERNAL_APP_LINK);
13
+ if (internalMatch) {
14
+ const slug = decodeURIComponent(internalMatch[1] ?? "");
15
+ const exists = resources.some(
16
+ (r) => {
17
+ var _a;
18
+ return r.slug === slug || ((_a = r.aliases) == null ? void 0 : _a.includes(slug));
19
+ }
20
+ );
21
+ if (!exists) {
22
+ return /* @__PURE__ */ jsx(Fragment, { children });
23
+ }
24
+ const isCurrent = pathname === `/app/${slug}`;
25
+ return /* @__PURE__ */ jsx(
26
+ Link,
27
+ {
28
+ to: "/app/$slug",
29
+ params: { slug },
30
+ search: (prev) => prev,
31
+ "aria-current": isCurrent ? "page" : void 0,
32
+ className: "text-primary hover:underline",
33
+ children
34
+ }
35
+ );
14
36
  }
15
- );
37
+ return /* @__PURE__ */ jsx(
38
+ "a",
39
+ {
40
+ href,
41
+ target: "_blank",
42
+ rel: "noopener noreferrer",
43
+ className: "text-primary hover:underline",
44
+ children
45
+ }
46
+ );
47
+ };
16
48
  function MarkdownText({
17
49
  children,
18
50
  className
@@ -1 +1 @@
1
- {"version":3,"file":"MarkdownText.js","sources":["../../../../../../src/modules/appCatalog/ui/components/MarkdownText.tsx"],"sourcesContent":["import type React from 'react'\nimport ReactMarkdown from 'react-markdown'\n\n/**\n * Renders a markdown link with security attributes (opens in a new tab,\n * no referrer/opener leakage). Shared so all catalog text fields render\n * links identically.\n */\nexport const MarkdownLink = ({\n href,\n children,\n}: {\n href?: string\n children?: React.ReactNode\n}) => (\n <a\n href={href}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-primary hover:underline\"\n >\n {children}\n </a>\n)\n\n/**\n * Renders user-facing catalog text (descriptions, comments, prompts) as\n * markdown so links become clickable. Links use {@link MarkdownLink}.\n */\nexport function MarkdownText({\n children,\n className,\n}: {\n children: string\n className?: string\n}) {\n return (\n <span className={className}>\n <ReactMarkdown components={{ a: MarkdownLink }}>{children}</ReactMarkdown>\n </span>\n )\n}\n"],"names":[],"mappings":";;AAQO,MAAM,eAAe,CAAC;AAAA,EAC3B;AAAA,EACA;AACF,MAIE;AAAA,EAAC;AAAA,EAAA;AAAA,IACC;AAAA,IACA,QAAO;AAAA,IACP,KAAI;AAAA,IACJ,WAAU;AAAA,IAET;AAAA,EAAA;AACH;AAOK,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AACF,GAGG;AACD,SACE,oBAAC,QAAA,EAAK,WACJ,UAAA,oBAAC,eAAA,EAAc,YAAY,EAAE,GAAG,aAAA,GAAiB,SAAA,CAAS,EAAA,CAC5D;AAEJ;"}
1
+ {"version":3,"file":"MarkdownText.js","sources":["../../../../../../src/modules/appCatalog/ui/components/MarkdownText.tsx"],"sourcesContent":["import { Link, useRouterState } from '@tanstack/react-router'\nimport type React from 'react'\nimport ReactMarkdown from 'react-markdown'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\n\n/** Match a relative internal catalog link: `/app/<slug>` (slug only, no extra path). */\nconst INTERNAL_APP_LINK = /^\\/app\\/([^/?#]+)$/\n\n/**\n * Renders a markdown link.\n *\n * Internal cross-references — a relative `/app/<slug>` pointing at another\n * catalog entry — navigate WITHIN the app via the TanStack router (same tab,\n * no full reload), so authors can cross-link entries with plain markdown\n * `[Name](/app/<slug>)` (#25). The slug is validated against the loaded\n * resources (canonical slug or a known alias); an unknown slug renders as\n * plain text rather than a dead link.\n *\n * Everything else (external http/https links) opens in a new tab with\n * `noopener noreferrer`, unchanged. Shared so all catalog text fields render\n * links identically.\n */\nexport const MarkdownLink = ({\n href,\n children,\n}: {\n href?: string\n children?: React.ReactNode\n}) => {\n const { resources } = useAppCatalogContext()\n const pathname = useRouterState({ select: (s) => s.location.pathname })\n\n const internalMatch = href?.match(INTERNAL_APP_LINK)\n if (internalMatch) {\n const slug = decodeURIComponent(internalMatch[1] ?? '')\n const exists = resources.some(\n (r) => r.slug === slug || r.aliases?.includes(slug),\n )\n // Unknown slug → render plain text, never a dead internal link.\n if (!exists) {\n return <>{children}</>\n }\n const isCurrent = pathname === `/app/${slug}`\n return (\n <Link\n to=\"/app/$slug\"\n params={{ slug }}\n search={(prev) => prev}\n aria-current={isCurrent ? 'page' : undefined}\n className=\"text-primary hover:underline\"\n >\n {children}\n </Link>\n )\n }\n\n return (\n <a\n href={href}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-primary hover:underline\"\n >\n {children}\n </a>\n )\n}\n\n/**\n * Renders user-facing catalog text (descriptions, comments, prompts) as\n * markdown so links become clickable. Links use {@link MarkdownLink}.\n */\nexport function MarkdownText({\n children,\n className,\n}: {\n children: string\n className?: string\n}) {\n return (\n <span className={className}>\n <ReactMarkdown components={{ a: MarkdownLink }}>{children}</ReactMarkdown>\n </span>\n )\n}\n"],"names":[],"mappings":";;;;AAMA,MAAM,oBAAoB;AAgBnB,MAAM,eAAe,CAAC;AAAA,EAC3B;AAAA,EACA;AACF,MAGM;AACJ,QAAM,EAAE,UAAA,IAAc,qBAAA;AACtB,QAAM,WAAW,eAAe,EAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,UAAU;AAEtE,QAAM,gBAAgB,6BAAM,MAAM;AAClC,MAAI,eAAe;AACjB,UAAM,OAAO,mBAAmB,cAAc,CAAC,KAAK,EAAE;AACtD,UAAM,SAAS,UAAU;AAAA,MACvB,CAAC;;AAAM,iBAAE,SAAS,UAAQ,OAAE,YAAF,mBAAW,SAAS;AAAA;AAAA,IAAI;AAGpD,QAAI,CAAC,QAAQ;AACX,6CAAU,UAAS;AAAA,IACrB;AACA,UAAM,YAAY,aAAa,QAAQ,IAAI;AAC3C,WACE;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,IAAG;AAAA,QACH,QAAQ,EAAE,KAAA;AAAA,QACV,QAAQ,CAAC,SAAS;AAAA,QAClB,gBAAc,YAAY,SAAS;AAAA,QACnC,WAAU;AAAA,QAET;AAAA,MAAA;AAAA,IAAA;AAAA,EAGP;AAEA,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC;AAAA,MACA,QAAO;AAAA,MACP,KAAI;AAAA,MACJ,WAAU;AAAA,MAET;AAAA,IAAA;AAAA,EAAA;AAGP;AAMO,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AACF,GAGG;AACD,SACE,oBAAC,QAAA,EAAK,WACJ,UAAA,oBAAC,eAAA,EAAc,YAAY,EAAE,GAAG,aAAA,GAAiB,SAAA,CAAS,EAAA,CAC5D;AAEJ;"}
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * Compact segmented toggle for the header (Apps | Service Desks). Rendered in
3
3
  * the header's `middle` slot so it adds no header height. Uses router Links so
4
- * the active segment reflects the current route and each is deep-linkable.
4
+ * each segment is deep-linkable.
5
+ *
6
+ * The active segment is derived from the current pathname rather than a plain
7
+ * exact-match on the Link: the "Apps" tab must stay active on the app-detail
8
+ * routes (`/app/<slug>`) too, since the detail panel is part of the Apps view
9
+ * (#23). A plain `activeOptions={{ exact: true }}` on `to="/"` left both tabs
10
+ * inactive on `/app/<slug>`.
5
11
  */
6
12
  export declare function ViewToggle(): import("react/jsx-runtime").JSX.Element;
@@ -1,7 +1,10 @@
1
1
  import { jsxs, jsx } from "react/jsx-runtime";
2
- import { Link } from "@tanstack/react-router";
2
+ import { useRouterState, Link } from "@tanstack/react-router";
3
3
  import { cn } from "../../../../lib/utils.js";
4
4
  function ViewToggle() {
5
+ const pathname = useRouterState({ select: (s) => s.location.pathname });
6
+ const appsActive = pathname === "/" || pathname.startsWith("/app/");
7
+ const desksActive = pathname.startsWith("/service-desks");
5
8
  const segment = "inline-flex items-center rounded-md px-3 py-1 text-sm font-medium transition-colors";
6
9
  const active = "bg-background text-foreground shadow-sm";
7
10
  const inactive = "text-muted-foreground hover:text-foreground";
@@ -17,9 +20,8 @@ function ViewToggle() {
17
20
  {
18
21
  to: "/",
19
22
  "aria-label": "Apps",
20
- className: cn(segment, inactive),
21
- activeOptions: { exact: true },
22
- activeProps: { className: cn(segment, active) },
23
+ "aria-current": appsActive ? "page" : void 0,
24
+ className: cn(segment, appsActive ? active : inactive),
23
25
  children: "Apps"
24
26
  }
25
27
  ),
@@ -28,8 +30,8 @@ function ViewToggle() {
28
30
  {
29
31
  to: "/service-desks",
30
32
  "aria-label": "Service Desks",
31
- className: cn(segment, inactive),
32
- activeProps: { className: cn(segment, active) },
33
+ "aria-current": desksActive ? "page" : void 0,
34
+ className: cn(segment, desksActive ? active : inactive),
33
35
  children: "Service Desks"
34
36
  }
35
37
  )
@@ -1 +1 @@
1
- {"version":3,"file":"ViewToggle.js","sources":["../../../../../../src/modules/appCatalog/ui/components/ViewToggle.tsx"],"sourcesContent":["import { Link } from '@tanstack/react-router'\nimport { cn } from '~/lib/utils'\n\n/**\n * Compact segmented toggle for the header (Apps | Service Desks). Rendered in\n * the header's `middle` slot so it adds no header height. Uses router Links so\n * the active segment reflects the current route and each is deep-linkable.\n */\nexport function ViewToggle() {\n const segment =\n 'inline-flex items-center rounded-md px-3 py-1 text-sm font-medium transition-colors'\n const active = 'bg-background text-foreground shadow-sm'\n const inactive = 'text-muted-foreground hover:text-foreground'\n\n return (\n <div\n role=\"tablist\"\n aria-label=\"View\"\n className=\"inline-flex items-center gap-1 rounded-lg bg-muted p-[3px]\"\n >\n <Link\n to=\"/\"\n aria-label=\"Apps\"\n className={cn(segment, inactive)}\n activeOptions={{ exact: true }}\n activeProps={{ className: cn(segment, active) }}\n >\n Apps\n </Link>\n <Link\n to=\"/service-desks\"\n aria-label=\"Service Desks\"\n className={cn(segment, inactive)}\n activeProps={{ className: cn(segment, active) }}\n >\n Service Desks\n </Link>\n </div>\n )\n}\n"],"names":[],"mappings":";;;AAQO,SAAS,aAAa;AAC3B,QAAM,UACJ;AACF,QAAM,SAAS;AACf,QAAM,WAAW;AAEjB,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,MAAK;AAAA,MACL,cAAW;AAAA,MACX,WAAU;AAAA,MAEV,UAAA;AAAA,QAAA;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,IAAG;AAAA,YACH,cAAW;AAAA,YACX,WAAW,GAAG,SAAS,QAAQ;AAAA,YAC/B,eAAe,EAAE,OAAO,KAAA;AAAA,YACxB,aAAa,EAAE,WAAW,GAAG,SAAS,MAAM,EAAA;AAAA,YAC7C,UAAA;AAAA,UAAA;AAAA,QAAA;AAAA,QAGD;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,IAAG;AAAA,YACH,cAAW;AAAA,YACX,WAAW,GAAG,SAAS,QAAQ;AAAA,YAC/B,aAAa,EAAE,WAAW,GAAG,SAAS,MAAM,EAAA;AAAA,YAC7C,UAAA;AAAA,UAAA;AAAA,QAAA;AAAA,MAED;AAAA,IAAA;AAAA,EAAA;AAGN;"}
1
+ {"version":3,"file":"ViewToggle.js","sources":["../../../../../../src/modules/appCatalog/ui/components/ViewToggle.tsx"],"sourcesContent":["import { Link, useRouterState } from '@tanstack/react-router'\nimport { cn } from '~/lib/utils'\n\n/**\n * Compact segmented toggle for the header (Apps | Service Desks). Rendered in\n * the header's `middle` slot so it adds no header height. Uses router Links so\n * each segment is deep-linkable.\n *\n * The active segment is derived from the current pathname rather than a plain\n * exact-match on the Link: the \"Apps\" tab must stay active on the app-detail\n * routes (`/app/<slug>`) too, since the detail panel is part of the Apps view\n * (#23). A plain `activeOptions={{ exact: true }}` on `to=\"/\"` left both tabs\n * inactive on `/app/<slug>`.\n */\nexport function ViewToggle() {\n const pathname = useRouterState({ select: (s) => s.location.pathname })\n\n const appsActive = pathname === '/' || pathname.startsWith('/app/')\n const desksActive = pathname.startsWith('/service-desks')\n\n const segment =\n 'inline-flex items-center rounded-md px-3 py-1 text-sm font-medium transition-colors'\n const active = 'bg-background text-foreground shadow-sm'\n const inactive = 'text-muted-foreground hover:text-foreground'\n\n return (\n <div\n role=\"tablist\"\n aria-label=\"View\"\n className=\"inline-flex items-center gap-1 rounded-lg bg-muted p-[3px]\"\n >\n <Link\n to=\"/\"\n aria-label=\"Apps\"\n aria-current={appsActive ? 'page' : undefined}\n className={cn(segment, appsActive ? active : inactive)}\n >\n Apps\n </Link>\n <Link\n to=\"/service-desks\"\n aria-label=\"Service Desks\"\n aria-current={desksActive ? 'page' : undefined}\n className={cn(segment, desksActive ? active : inactive)}\n >\n Service Desks\n </Link>\n </div>\n )\n}\n"],"names":[],"mappings":";;;AAcO,SAAS,aAAa;AAC3B,QAAM,WAAW,eAAe,EAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,UAAU;AAEtE,QAAM,aAAa,aAAa,OAAO,SAAS,WAAW,OAAO;AAClE,QAAM,cAAc,SAAS,WAAW,gBAAgB;AAExD,QAAM,UACJ;AACF,QAAM,SAAS;AACf,QAAM,WAAW;AAEjB,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,MAAK;AAAA,MACL,cAAW;AAAA,MACX,WAAU;AAAA,MAEV,UAAA;AAAA,QAAA;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,IAAG;AAAA,YACH,cAAW;AAAA,YACX,gBAAc,aAAa,SAAS;AAAA,YACpC,WAAW,GAAG,SAAS,aAAa,SAAS,QAAQ;AAAA,YACtD,UAAA;AAAA,UAAA;AAAA,QAAA;AAAA,QAGD;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,IAAG;AAAA,YACH,cAAW;AAAA,YACX,gBAAc,cAAc,SAAS;AAAA,YACrC,WAAW,GAAG,SAAS,cAAc,SAAS,QAAQ;AAAA,YACvD,UAAA;AAAA,UAAA;AAAA,QAAA;AAAA,MAED;AAAA,IAAA;AAAA,EAAA;AAGN;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Frontend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -134,8 +134,8 @@
134
134
  "vite-plugin-static-copy": "^3.1.4",
135
135
  "vite-plugin-svgr": "^4.2.0",
136
136
  "vitest": "^4.1.2",
137
- "@igstack/app-catalog-backend-core": "0.9.0",
138
- "@igstack/app-catalog-shared-core": "0.9.0"
137
+ "@igstack/app-catalog-backend-core": "0.9.2",
138
+ "@igstack/app-catalog-shared-core": "0.9.2"
139
139
  },
140
140
  "peerDependencies": {
141
141
  "react": "19.1.2",
@@ -144,7 +144,7 @@
144
144
  "vite": "^6.3.5",
145
145
  "vite-plugin-svgr": "^4.2.0"
146
146
  },
147
- "gitHead": "a27e7c29dd93494100f891f47b2bc4cea4091125",
147
+ "gitHead": "bffb2faf1feacac02c3c49096dbb5eb18f54cd12",
148
148
  "scripts": {
149
149
  "build": "vite build",
150
150
  "build:lenient": "vite build --mode lenient",
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { screen, waitFor } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import '@testing-library/jest-dom/vitest'
5
+ import { given } from './harness/given'
6
+ import { magazine } from './mock-backend/magazines'
7
+
8
+ // #25: when an app description references another catalog app via a relative
9
+ // markdown link `[Name](/app/<slug>)`, it must navigate WITHIN the catalog
10
+ // (TanStack Router, same tab) rather than opening a new browser tab. External
11
+ // links stay `target=_blank`; links to unknown slugs render as plain text so
12
+ // there are no dead internal links.
13
+ describe('Cross-reference links between app entries (#25)', () => {
14
+ const withCrossRefApp = magazine.full(({ backendCfg }) => {
15
+ backendCfg.withApp({
16
+ slug: 'portals-hub',
17
+ displayName: 'Portals Hub',
18
+ description:
19
+ 'See the [Jira](/app/jira) entry and the external [docs](https://example.com/docs). Also [Ghost](/app/no-such-app) which does not exist.',
20
+ })
21
+ })
22
+
23
+ it('navigates in-app (same tab, no new tab) when clicking an internal /app/<slug> link', async () => {
24
+ const { ui, router } = await given(withCrossRefApp, {
25
+ initialRoute: '/app/portals-hub',
26
+ })
27
+ await waitFor(() => {
28
+ expect(ui.catalog.isDetailPanelOpen()).toBe(true)
29
+ })
30
+
31
+ const jiraLink = screen.getByRole('link', { name: 'Jira' })
32
+ // Internal link must NOT open a new tab.
33
+ expect(jiraLink).not.toHaveAttribute('target', '_blank')
34
+ // It points at the router path (rendered as an href).
35
+ expect(jiraLink).toHaveAttribute('href', '/app/jira')
36
+
37
+ const user = userEvent.setup()
38
+ await user.click(jiraLink)
39
+
40
+ // In-app navigation: the router path changes and Jira's detail opens,
41
+ // without a full page reload / new tab.
42
+ await waitFor(() => {
43
+ expect(router.state.location.pathname).toBe('/app/jira')
44
+ })
45
+ await waitFor(() => {
46
+ expect(ui.app.getOpenTitle()).toContain('Jira')
47
+ })
48
+ })
49
+
50
+ it('sets aria-current="page" on an internal link that points at the open app', async () => {
51
+ const { ui } = await given(
52
+ magazine.full(({ backendCfg }) => {
53
+ backendCfg.withApp({
54
+ slug: 'self-ref',
55
+ displayName: 'Self Ref',
56
+ description: 'This links to [itself](/app/self-ref) recursively.',
57
+ })
58
+ }),
59
+ { initialRoute: '/app/self-ref' },
60
+ )
61
+ await waitFor(() => {
62
+ expect(ui.catalog.isDetailPanelOpen()).toBe(true)
63
+ })
64
+ const selfLink = screen.getByRole('link', { name: 'itself' })
65
+ expect(selfLink).toHaveAttribute('aria-current', 'page')
66
+ })
67
+
68
+ it('renders a link to a non-existent slug as plain text (no dead link)', async () => {
69
+ const { ui } = await given(withCrossRefApp, {
70
+ initialRoute: '/app/portals-hub',
71
+ })
72
+ await waitFor(() => {
73
+ expect(ui.catalog.isDetailPanelOpen()).toBe(true)
74
+ })
75
+ // "Ghost" text is present (as plain text merged into the paragraph)…
76
+ expect(screen.getAllByText(/Ghost.*does not exist/).length).toBeGreaterThan(
77
+ 0,
78
+ )
79
+ // …but it is NOT a link (no dead internal link).
80
+ expect(screen.queryByRole('link', { name: 'Ghost' })).toBeNull()
81
+ })
82
+
83
+ it('keeps external links opening in a new tab', async () => {
84
+ const { ui } = await given(withCrossRefApp, {
85
+ initialRoute: '/app/portals-hub',
86
+ })
87
+ await waitFor(() => {
88
+ expect(ui.catalog.isDetailPanelOpen()).toBe(true)
89
+ })
90
+ const docsLink = screen.getByRole('link', { name: 'docs' })
91
+ expect(docsLink).toHaveAttribute('target', '_blank')
92
+ expect(docsLink).toHaveAttribute('rel', expect.stringContaining('noopener'))
93
+ expect(docsLink).toHaveAttribute('href', 'https://example.com/docs')
94
+ })
95
+ })
@@ -123,3 +123,39 @@ describe('Service Desks view (#9)', () => {
123
123
  await waitFor(() => expect(search).toHaveFocus())
124
124
  })
125
125
  })
126
+
127
+ // #23: the header "Apps" tab must stay active while viewing an app-detail route
128
+ // (/app/<slug>) — the detail panel is part of the Apps view. Previously the Apps
129
+ // link was active only on the exact "/" route, so /app/quicksight highlighted
130
+ // neither tab.
131
+ describe('ViewToggle active tab (#23)', () => {
132
+ const appsTab = () => screen.getByRole('link', { name: 'Apps' })
133
+ const deskTab = () => screen.getByRole('link', { name: 'Service Desks' })
134
+
135
+ it('activates the Apps tab on an app-detail route (/app/$slug)', async () => {
136
+ await given(magazine.full(), { initialRoute: '/app/jira' })
137
+
138
+ await waitFor(() =>
139
+ expect(appsTab()).toHaveAttribute('aria-current', 'page'),
140
+ )
141
+ expect(deskTab()).not.toHaveAttribute('aria-current')
142
+ })
143
+
144
+ it('activates the Apps tab on the root route', async () => {
145
+ await given(magazine.full(), { initialRoute: '/' })
146
+
147
+ await waitFor(() =>
148
+ expect(appsTab()).toHaveAttribute('aria-current', 'page'),
149
+ )
150
+ expect(deskTab()).not.toHaveAttribute('aria-current')
151
+ })
152
+
153
+ it('activates only Service Desks on the /service-desks route', async () => {
154
+ await given(magazine.full(), { initialRoute: '/service-desks' })
155
+
156
+ await waitFor(() =>
157
+ expect(deskTab()).toHaveAttribute('aria-current', 'page'),
158
+ )
159
+ expect(appsTab()).not.toHaveAttribute('aria-current')
160
+ })
161
+ })
@@ -1,9 +1,23 @@
1
+ import { Link, useRouterState } from '@tanstack/react-router'
1
2
  import type React from 'react'
2
3
  import ReactMarkdown from 'react-markdown'
4
+ import { useAppCatalogContext } from '../../context/AppCatalogContext'
5
+
6
+ /** Match a relative internal catalog link: `/app/<slug>` (slug only, no extra path). */
7
+ const INTERNAL_APP_LINK = /^\/app\/([^/?#]+)$/
3
8
 
4
9
  /**
5
- * Renders a markdown link with security attributes (opens in a new tab,
6
- * no referrer/opener leakage). Shared so all catalog text fields render
10
+ * Renders a markdown link.
11
+ *
12
+ * Internal cross-references — a relative `/app/<slug>` pointing at another
13
+ * catalog entry — navigate WITHIN the app via the TanStack router (same tab,
14
+ * no full reload), so authors can cross-link entries with plain markdown
15
+ * `[Name](/app/<slug>)` (#25). The slug is validated against the loaded
16
+ * resources (canonical slug or a known alias); an unknown slug renders as
17
+ * plain text rather than a dead link.
18
+ *
19
+ * Everything else (external http/https links) opens in a new tab with
20
+ * `noopener noreferrer`, unchanged. Shared so all catalog text fields render
7
21
  * links identically.
8
22
  */
9
23
  export const MarkdownLink = ({
@@ -12,16 +26,45 @@ export const MarkdownLink = ({
12
26
  }: {
13
27
  href?: string
14
28
  children?: React.ReactNode
15
- }) => (
16
- <a
17
- href={href}
18
- target="_blank"
19
- rel="noopener noreferrer"
20
- className="text-primary hover:underline"
21
- >
22
- {children}
23
- </a>
24
- )
29
+ }) => {
30
+ const { resources } = useAppCatalogContext()
31
+ const pathname = useRouterState({ select: (s) => s.location.pathname })
32
+
33
+ const internalMatch = href?.match(INTERNAL_APP_LINK)
34
+ if (internalMatch) {
35
+ const slug = decodeURIComponent(internalMatch[1] ?? '')
36
+ const exists = resources.some(
37
+ (r) => r.slug === slug || r.aliases?.includes(slug),
38
+ )
39
+ // Unknown slug → render plain text, never a dead internal link.
40
+ if (!exists) {
41
+ return <>{children}</>
42
+ }
43
+ const isCurrent = pathname === `/app/${slug}`
44
+ return (
45
+ <Link
46
+ to="/app/$slug"
47
+ params={{ slug }}
48
+ search={(prev) => prev}
49
+ aria-current={isCurrent ? 'page' : undefined}
50
+ className="text-primary hover:underline"
51
+ >
52
+ {children}
53
+ </Link>
54
+ )
55
+ }
56
+
57
+ return (
58
+ <a
59
+ href={href}
60
+ target="_blank"
61
+ rel="noopener noreferrer"
62
+ className="text-primary hover:underline"
63
+ >
64
+ {children}
65
+ </a>
66
+ )
67
+ }
25
68
 
26
69
  /**
27
70
  * Renders user-facing catalog text (descriptions, comments, prompts) as
@@ -1,12 +1,23 @@
1
- import { Link } from '@tanstack/react-router'
1
+ import { Link, useRouterState } from '@tanstack/react-router'
2
2
  import { cn } from '~/lib/utils'
3
3
 
4
4
  /**
5
5
  * Compact segmented toggle for the header (Apps | Service Desks). Rendered in
6
6
  * the header's `middle` slot so it adds no header height. Uses router Links so
7
- * the active segment reflects the current route and each is deep-linkable.
7
+ * each segment is deep-linkable.
8
+ *
9
+ * The active segment is derived from the current pathname rather than a plain
10
+ * exact-match on the Link: the "Apps" tab must stay active on the app-detail
11
+ * routes (`/app/<slug>`) too, since the detail panel is part of the Apps view
12
+ * (#23). A plain `activeOptions={{ exact: true }}` on `to="/"` left both tabs
13
+ * inactive on `/app/<slug>`.
8
14
  */
9
15
  export function ViewToggle() {
16
+ const pathname = useRouterState({ select: (s) => s.location.pathname })
17
+
18
+ const appsActive = pathname === '/' || pathname.startsWith('/app/')
19
+ const desksActive = pathname.startsWith('/service-desks')
20
+
10
21
  const segment =
11
22
  'inline-flex items-center rounded-md px-3 py-1 text-sm font-medium transition-colors'
12
23
  const active = 'bg-background text-foreground shadow-sm'
@@ -21,17 +32,16 @@ export function ViewToggle() {
21
32
  <Link
22
33
  to="/"
23
34
  aria-label="Apps"
24
- className={cn(segment, inactive)}
25
- activeOptions={{ exact: true }}
26
- activeProps={{ className: cn(segment, active) }}
35
+ aria-current={appsActive ? 'page' : undefined}
36
+ className={cn(segment, appsActive ? active : inactive)}
27
37
  >
28
38
  Apps
29
39
  </Link>
30
40
  <Link
31
41
  to="/service-desks"
32
42
  aria-label="Service Desks"
33
- className={cn(segment, inactive)}
34
- activeProps={{ className: cn(segment, active) }}
43
+ aria-current={desksActive ? 'page' : undefined}
44
+ className={cn(segment, desksActive ? active : inactive)}
35
45
  >
36
46
  Service Desks
37
47
  </Link>