@igstack/app-catalog-frontend-core 0.9.1 → 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.
- package/dist/esm/__tests__/integration/crossRefLink.integration.test.d.ts +0 -0
- package/dist/esm/modules/appCatalog/ui/components/MarkdownText.d.ts +11 -2
- package/dist/esm/modules/appCatalog/ui/components/MarkdownText.js +42 -10
- package/dist/esm/modules/appCatalog/ui/components/MarkdownText.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/integration/crossRefLink.integration.test.ts +95 -0
- package/src/modules/appCatalog/ui/components/MarkdownText.tsx +55 -12
|
File without changes
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { default as React } from 'react';
|
|
2
2
|
/**
|
|
3
|
-
* Renders a markdown link
|
|
4
|
-
*
|
|
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
|
-
}) =>
|
|
7
|
-
|
|
8
|
-
{
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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;"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-frontend-core",
|
|
3
|
-
"version": "0.9.
|
|
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.
|
|
138
|
-
"@igstack/app-catalog-shared-core": "0.9.
|
|
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": "
|
|
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
|
+
})
|
|
@@ -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
|
|
6
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|