@conduction/docusaurus-preset 1.7.0 → 2.1.0

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": "@conduction/docusaurus-preset",
3
- "version": "1.7.0",
3
+ "version": "2.1.0",
4
4
  "scripts": {
5
5
  "prepack": "node scripts/prepack-bundle-css.js"
6
6
  },
@@ -3,10 +3,13 @@
3
3
  *
4
4
  * Partner card from preview/components/partner-cards.html.
5
5
  *
6
- * Three tiers:
7
- * - partner (default, white panel + cobalt-50 tier pill)
8
- * - certified (white panel + gold tier pill)
9
- * - strategic (cobalt-fill inverse + white tier pill)
6
+ * Three tiers (low → high):
7
+ * - host (default, white panel + cobalt-50 tier pill)
8
+ * ships our apps, no SLA with Conduction.
9
+ * - service (white panel + gold tier pill) — SLA, third-line
10
+ * support, roadmap input.
11
+ * - certified (cobalt-fill inverse + white tier pill) — trained,
12
+ * joint roadmap, tender-eligible.
10
13
  *
11
14
  * Visual structure:
12
15
  * ┌─────────────────────────────┐
@@ -25,7 +28,7 @@
25
28
  * <PartnerGrid>
26
29
  * <PartnerCard
27
30
  * href="/partners/yard"
28
- * tier="partner"
31
+ * tier="host"
29
32
  * name="YARD"
30
33
  * logo="/img/partners/yard.png"
31
34
  * summary={<>Digital design- en development-bureau uit Utrecht...</>}
@@ -37,9 +40,10 @@
37
40
 
38
41
  import React from 'react';
39
42
  import HexBullet from '../primitives/HexBullet';
43
+ import {appHrefByName} from '../../data/apps-registry';
40
44
  import styles from './PartnerCard.module.css';
41
45
 
42
- const TIER_LABELS = {partner: 'Partner', certified: 'Certified', strategic: 'Strategic'};
46
+ const TIER_LABELS = {host: 'Host', service: 'Service', certified: 'Certified'};
43
47
 
44
48
  export function PartnerGrid({columns = 3, children, className}) {
45
49
  const composed = [styles.grid, styles['cols-' + columns], className].filter(Boolean).join(' ');
@@ -49,7 +53,7 @@ export function PartnerGrid({columns = 3, children, className}) {
49
53
  export default function PartnerCard({
50
54
  variant = 'full',
51
55
  href,
52
- tier = 'partner',
56
+ tier = 'host',
53
57
  name,
54
58
  logo,
55
59
  logoAlt,
@@ -85,17 +89,30 @@ export default function PartnerCard({
85
89
  );
86
90
  }
87
91
 
88
- /* Default: full partner card */
92
+ /* Default: full partner card.
93
+ The card wrapper is always a <div> rather than an <a>, even when
94
+ a partner detail href is set, so the per-app pills can be
95
+ individually clickable links to /apps/<slug>. The card-wide click
96
+ target is rendered as a stretched-link overlay (.cardLink) that
97
+ covers the whole card via position:absolute; nested <a> pills
98
+ sit on top via z-index, so clicks reach the pill instead of the
99
+ overlay when they land on a pill, and reach the overlay otherwise. */
89
100
  const composed = [
90
101
  styles.card,
91
102
  styles['tier-' + tier],
92
103
  className,
93
104
  ].filter(Boolean).join(' ');
94
- const Tag = href ? 'a' : 'div';
95
- const bulletColor = tier === 'strategic' ? 'var(--c-orange-knvb)' : 'var(--c-blue-cobalt)';
105
+ const bulletColor = tier === 'certified' ? 'var(--c-orange-knvb)' : 'var(--c-blue-cobalt)';
96
106
 
97
107
  return (
98
- <Tag href={href} className={composed}>
108
+ <div className={composed}>
109
+ {href && (
110
+ <a
111
+ href={href}
112
+ className={styles.cardLink}
113
+ aria-label={name ? `${name} partner page` : 'Partner page'}
114
+ />
115
+ )}
99
116
  <span className={styles.tier}>{TIER_LABELS[tier] || tier}</span>
100
117
  {logo && (
101
118
  <div className={styles.avatar}>
@@ -106,15 +123,29 @@ export default function PartnerCard({
106
123
  {summary && <div className={styles.summary}>{summary}</div>}
107
124
  {apps.length > 0 && (
108
125
  <div className={styles.apps}>
109
- {apps.map((app, i) => (
110
- <span key={i} className={styles.appPill}>
111
- <HexBullet size="sm" color={bulletColor} />
112
- {app}
113
- </span>
114
- ))}
126
+ {apps.map((app, i) => {
127
+ const appUrl = appHrefByName(app);
128
+ const inner = (
129
+ <>
130
+ <HexBullet size="sm" color={bulletColor} />
131
+ {app}
132
+ </>
133
+ );
134
+ return appUrl ? (
135
+ <a
136
+ key={app}
137
+ href={appUrl}
138
+ className={styles.appPill}
139
+ >
140
+ {inner}
141
+ </a>
142
+ ) : (
143
+ <span key={app} className={styles.appPill}>{inner}</span>
144
+ );
145
+ })}
115
146
  </div>
116
147
  )}
117
- </Tag>
148
+ </div>
118
149
  );
119
150
  }
120
151
 
@@ -33,6 +33,21 @@
33
33
  text-decoration: none;
34
34
  color: inherit;
35
35
  }
36
+ /* Stretched-link overlay. Covers the whole card so any click on
37
+ non-interactive content navigates to the partner detail page,
38
+ while leaving room for nested links (app pills) to take precedence
39
+ via z-index. The overlay carries the aria-label since the card
40
+ itself is no longer the anchor. */
41
+ .cardLink {
42
+ position: absolute;
43
+ inset: 0;
44
+ border-radius: inherit;
45
+ z-index: 1;
46
+ }
47
+ .cardLink:focus-visible {
48
+ outline: 2px solid var(--c-blue-cobalt);
49
+ outline-offset: -2px;
50
+ }
36
51
 
37
52
  .tier {
38
53
  position: absolute;
@@ -49,9 +64,16 @@
49
64
  color: var(--c-cobalt-700);
50
65
  }
51
66
 
52
- .tier-certified .tier { background: var(--c-gold-500); color: var(--c-cobalt-900); }
53
- .tier-strategic { background: var(--c-blue-cobalt); color: white; border-color: var(--c-blue-cobalt); }
54
- .tier-strategic .tier { background: white; color: var(--c-blue-cobalt); }
67
+ /* Service tier: gold pill on a white card. */
68
+ .tier-service .tier { background: var(--c-gold-500); color: var(--c-cobalt-900); }
69
+
70
+ /* Certified tier: cobalt-fill inverse card. The compound .card.tier-certified
71
+ selector is deliberate — it lifts specificity above the plain .card rule
72
+ so the cobalt fill reliably wins over .card { background: white } even
73
+ after CSS-module bundlers reshuffle declaration order. Same for the
74
+ nested colour rules below. */
75
+ .card.tier-certified { background: var(--c-blue-cobalt); color: white; border-color: var(--c-blue-cobalt); }
76
+ .tier-certified .tier { background: white; color: var(--c-blue-cobalt); }
55
77
 
56
78
  .avatar {
57
79
  display: inline-flex;
@@ -73,12 +95,12 @@
73
95
  }
74
96
 
75
97
  .name { font-size: 18px; font-weight: 700; color: var(--c-cobalt-900); }
76
- .tier-strategic .name { color: white; }
98
+ .tier-certified .name { color: white; }
77
99
 
78
100
  .summary { font-size: 14px; line-height: 1.55; color: var(--c-cobalt-700); }
79
101
  .summary :global(.next-blue) { color: var(--c-nextcloud-blue); }
80
- .tier-strategic .summary { color: rgba(255, 255, 255, 0.78); }
81
- .tier-strategic .summary :global(.next-blue) { color: var(--c-nextcloud-cyan); }
102
+ .tier-certified .summary { color: rgba(255, 255, 255, 0.78); }
103
+ .tier-certified .summary :global(.next-blue) { color: var(--c-nextcloud-cyan); }
82
104
 
83
105
  .apps {
84
106
  margin-top: auto;
@@ -88,7 +110,7 @@
88
110
  gap: 6px;
89
111
  flex-wrap: wrap;
90
112
  }
91
- .tier-strategic .apps { border-top-color: rgba(255, 255, 255, 0.18); }
113
+ .tier-certified .apps { border-top-color: rgba(255, 255, 255, 0.18); }
92
114
 
93
115
  .appPill {
94
116
  display: inline-flex;
@@ -100,8 +122,26 @@
100
122
  border-radius: var(--radius-pill);
101
123
  font-size: 12px;
102
124
  font-family: var(--conduction-typography-font-family-code);
125
+ /* When the pill is rendered as <a> it sits above the cardLink
126
+ overlay so clicks land on the pill, not the card-wide link. */
127
+ position: relative;
128
+ z-index: 2;
129
+ text-decoration: none;
130
+ transition: background 160ms, color 160ms;
131
+ }
132
+ /* Hover affordance only when the pill is a real link (i.e. the
133
+ app is in the registry). Plain <span> pills (e.g. "Nextcloud")
134
+ stay static. */
135
+ a.appPill:hover {
136
+ background: var(--c-blue-cobalt);
137
+ color: white;
138
+ text-decoration: none;
139
+ }
140
+ .card.tier-certified a.appPill:hover {
141
+ background: white;
142
+ color: var(--c-blue-cobalt);
103
143
  }
104
- .tier-strategic .appPill { background: rgba(255, 255, 255, 0.12); color: white; }
144
+ .tier-certified .appPill { background: rgba(255, 255, 255, 0.12); color: white; }
105
145
 
106
146
  /* Become-a-partner CTA tile */
107
147
  .become {
@@ -182,8 +222,8 @@
182
222
  max-height: 28px;
183
223
  object-fit: contain;
184
224
  }
185
- .other.tier-certified .miniAvatar { box-shadow: inset 0 0 0 2px var(--c-gold-500); }
186
- .other.tier-strategic .miniAvatar { box-shadow: inset 0 0 0 2px var(--c-orange-knvb); }
225
+ .other.tier-service .miniAvatar { box-shadow: inset 0 0 0 2px var(--c-gold-500); }
226
+ .other.tier-certified .miniAvatar { box-shadow: inset 0 0 0 2px var(--c-orange-knvb); }
187
227
 
188
228
  .otherName {
189
229
  font-size: 15px;
@@ -5,27 +5,32 @@
5
5
  * <PartnerGrid/>, <PartnerCard/>, and an optional <BecomePartner/> CTA.
6
6
  *
7
7
  * Facets are auto-derived from the partners array:
8
- * - tier: Strategic / Certified / Partner (only shown when ≥1 partner)
9
- * - app: union of every partners[].apps[] value, sorted by count
8
+ * - tier: Certified / Service / Host (only shown when ≥1 partner)
9
+ * - offering: union of every partners[].apps[] AND .solutions[]
10
+ * value, sorted by count. Solutions get pretty labels;
11
+ * unknown slugs fall back to the slug itself.
10
12
  *
11
13
  * Filter logic: a partner is shown when it matches every active facet.
12
14
  * Within a facet, multiple selections are OR-ed (any-of). Across
13
- * facets, selections are AND-ed (all-of).
15
+ * facets, selections are AND-ed (all-of). The offering facet matches
16
+ * against the union of the partner's apps and solutions, so picking
17
+ * "Woo" shows every partner that ships the Woo solution.
14
18
  *
15
19
  * Usage in MDX:
16
20
  *
17
21
  * <PartnerDirectory
18
22
  * partners={[
19
- * {href: '/partners/acato', tier: 'strategic', name: 'Acato',
23
+ * {href: '/partners/acato', tier: 'certified', name: 'Acato',
20
24
  * logo: '/img/partners/acato.png',
21
- * summary: <>Nederlandse open-source specialist…</>,
22
- * apps: ['OpenRegister', 'OpenCatalogi', 'DocuDesk']},
25
+ * summary: <>Digital agency uit Almere…</>,
26
+ * apps: ['OpenCatalogi', 'OpenRegister', 'OpenConnector'],
27
+ * solutions: ['woo']},
23
28
  * …
24
29
  * ]}
25
30
  * becomePartner={{
26
31
  * href: '#become-a-partner',
27
32
  * title: 'Ship Conduction to your customers.',
28
- * body: 'Join as Partner, Certified, or Strategic.',
33
+ * body: 'Three tiers: Host, Service, Certified.',
29
34
  * ctaLabel: 'Apply below',
30
35
  * }}
31
36
  * />
@@ -36,8 +41,21 @@ import FacetedFilters from '../FacetedFilters/FacetedFilters';
36
41
  import PartnerCard, {PartnerGrid, BecomePartner} from '../PartnerCard/PartnerCard';
37
42
  import styles from './PartnerDirectory.module.css';
38
43
 
39
- const TIER_LABELS = {partner: 'Partner', certified: 'Certified', strategic: 'Strategic'};
40
- const TIER_ORDER = ['strategic', 'certified', 'partner'];
44
+ const TIER_LABELS = {host: 'Host', service: 'Service', certified: 'Certified'};
45
+ const TIER_ORDER = ['certified', 'service', 'host'];
46
+
47
+ // Pretty labels for solution slugs that the catalog uses. Anything not
48
+ // in this map falls back to the raw slug, so a new solution keeps
49
+ // working without an explicit registration.
50
+ const SOLUTION_LABELS = {
51
+ woo: 'Woo',
52
+ archief: 'Archief',
53
+ 'software-catalog': 'Software catalog',
54
+ 'mkb-workspace': 'MKB workspace',
55
+ zaakafhandeling: 'Zaakafhandeling',
56
+ 'legacy-erp': 'Legacy ERP',
57
+ 'nen-7510': 'NEN 7510',
58
+ };
41
59
 
42
60
  function deriveFacets(partners) {
43
61
  const tierItems = TIER_ORDER
@@ -48,19 +66,29 @@ function deriveFacets(partners) {
48
66
  }))
49
67
  .filter(item => item.count > 0);
50
68
 
51
- const appCounts = {};
69
+ // Combined offering facet: apps + solutions. Each entry records its
70
+ // kind so the filter can match against the right partner field.
71
+ // Items are stored under a stable value namespaced by kind
72
+ // ('app:OpenCatalogi', 'solution:woo') so an app and a solution
73
+ // with the same name never collide.
74
+ const offeringCounts = new Map(); // value → {label, count, kind, raw}
75
+ const bump = (kind, raw, label) => {
76
+ const value = `${kind}:${raw}`;
77
+ const existing = offeringCounts.get(value);
78
+ if (existing) existing.count += 1;
79
+ else offeringCounts.set(value, {value, label, count: 1, kind, raw});
80
+ };
52
81
  for (const p of partners) {
53
- for (const a of p.apps || []) {
54
- appCounts[a] = (appCounts[a] || 0) + 1;
55
- }
82
+ for (const a of p.apps || []) bump('app', a, a);
83
+ for (const s of p.solutions || []) bump('solution', s, SOLUTION_LABELS[s] || s);
56
84
  }
57
- const appItems = Object.entries(appCounts)
58
- .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
59
- .map(([value, count]) => ({value, label: value, count}));
85
+ const offeringItems = Array.from(offeringCounts.values())
86
+ .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label))
87
+ .map(({value, label, count}) => ({value, label, count}));
60
88
 
61
89
  const facets = [];
62
- if (tierItems.length > 0) facets.push({key: 'tier', label: 'Partner tier', items: tierItems});
63
- if (appItems.length > 0) facets.push({key: 'app', label: 'Apps & services', items: appItems});
90
+ if (tierItems.length > 0) facets.push({key: 'tier', label: 'Partner tier', items: tierItems});
91
+ if (offeringItems.length > 0) facets.push({key: 'offering', label: 'Apps and solutions', items: offeringItems});
64
92
  return facets;
65
93
  }
66
94
 
@@ -68,10 +96,17 @@ function partnerMatches(partner, selected) {
68
96
  const tierFilter = selected.tier || [];
69
97
  if (tierFilter.length > 0 && !tierFilter.includes(partner.tier)) return false;
70
98
 
71
- const appFilter = selected.app || [];
72
- if (appFilter.length > 0) {
99
+ const offeringFilter = selected.offering || [];
100
+ if (offeringFilter.length > 0) {
73
101
  const partnerApps = partner.apps || [];
74
- if (!appFilter.some(a => partnerApps.includes(a))) return false;
102
+ const partnerSolutions = partner.solutions || [];
103
+ const ok = offeringFilter.some(value => {
104
+ const [kind, raw] = value.split(':');
105
+ if (kind === 'app') return partnerApps.includes(raw);
106
+ if (kind === 'solution') return partnerSolutions.includes(raw);
107
+ return false;
108
+ });
109
+ if (!ok) return false;
75
110
  }
76
111
 
77
112
  return true;
@@ -4,10 +4,10 @@
4
4
  * Narrow info panel for the partner detail page right column. Three
5
5
  * stacked sections:
6
6
  * 1. Tier badge. Pulls the same colour treatment as <PartnerCard>
7
- * (Partner = cobalt-50 pill, Certified = gold pill
8
- * with a small gold Conduction avatar to read as a
9
- * Conduction-issued credential, Strategic = white
10
- * pill on a cobalt-fill card).
7
+ * (Host = cobalt-50 pill, Service = gold pill,
8
+ * Certified = white pill on a cobalt-fill card,
9
+ * with a small gold Conduction avatar to read as
10
+ * a Conduction-issued credential).
11
11
  * 2. Apps supported. Chip row from partner.apps.
12
12
  * 3. Solutions shipped. Chip row from solutionsBySlugs(partner.solutions),
13
13
  * each linking to its /solutions/<slug> page.
@@ -37,21 +37,22 @@
37
37
  */
38
38
 
39
39
  import React from 'react';
40
+ import {appHrefByName} from '../../data/apps-registry';
40
41
  import styles from './PartnerSidecard.module.css';
41
42
 
42
43
  const TIER_LABELS = {
43
- partner: 'Partner',
44
+ host: 'Host',
45
+ service: 'Service',
44
46
  certified: 'Certified',
45
- strategic: 'Strategic',
46
47
  };
47
48
 
48
49
  const TIER_BLURB = {
49
- partner:
50
- 'Partner-tier teams have shipped Conduction in production at one or two clients. Listed in the directory, on the community Slack, joining quarterly office hours.',
50
+ host:
51
+ 'Host partners ship our open-source apps to their customers. They sell, host, and resell Nextcloud-supported variants through their own channels. No SLA with Conduction, no direct line to our support, not eligible to respond to tenders with our products.',
52
+ service:
53
+ 'Service partners hold an SLA with Conduction. Their team can call us directly for third-line support, with named contacts and input on bug priority and feature requests. They run rollouts; we back them up.',
51
54
  certified:
52
- 'Certified per app by Conduction. Joint go-to-market on tenders and a higher SLA on shared customers. Carries the Conduction credential.',
53
- strategic:
54
- 'Strategic partner with shared roadmap commitments and joint engineering. By invitation. Co-sale on government tenders, co-branded hosting offerings.',
55
+ 'Certified partners are trained, audited, and on the joint roadmap with Conduction. Eligible to co-sale on public tenders with our products, and the Conduction-credential mark on their own collateral.',
55
56
  };
56
57
 
57
58
  export default function PartnerSidecard({
@@ -61,7 +62,7 @@ export default function PartnerSidecard({
61
62
  className,
62
63
  }) {
63
64
  if (!partner) return null;
64
- const tier = partner.tier || 'partner';
65
+ const tier = partner.tier || 'host';
65
66
  const tierLabel = TIER_LABELS[tier] || tier;
66
67
  const composed = [styles.rail, styles[`tier-${tier}`], className].filter(Boolean).join(' ');
67
68
 
@@ -71,13 +72,13 @@ export default function PartnerSidecard({
71
72
  {tier === 'certified' && (
72
73
  <img
73
74
  className={styles.tierBadge}
74
- src="/img/brand/avatar-conduction-gold-on-white.svg"
75
+ src="/img/brand/avatar-conduction-gold.svg"
75
76
  alt="Conduction-certified mark"
76
77
  width="44"
77
78
  height="44"
78
79
  />
79
80
  )}
80
- <div className={styles.tierLabel}>{tier === 'partner' ? 'Partner' : `${tierLabel} partner`}</div>
81
+ <div className={styles.tierLabel}>{`${tierLabel} partner`}</div>
81
82
  <p className={styles.tierBlurb}>{TIER_BLURB[tier]}</p>
82
83
  </div>
83
84
 
@@ -85,9 +86,14 @@ export default function PartnerSidecard({
85
86
  <div className={styles.section}>
86
87
  <h4 className={styles.sectionTitle}>Apps they ship</h4>
87
88
  <div className={styles.chipRow}>
88
- {partner.apps.map((app) => (
89
- <span key={app} className={styles.chip}>{app}</span>
90
- ))}
89
+ {partner.apps.map((app) => {
90
+ const url = appHrefByName(app);
91
+ return url ? (
92
+ <a key={app} href={url} className={styles.chip}>{app}</a>
93
+ ) : (
94
+ <span key={app} className={styles.chip}>{app}</span>
95
+ );
96
+ })}
91
97
  </div>
92
98
  </div>
93
99
  )}
@@ -25,15 +25,17 @@
25
25
  box-shadow: var(--shadow-1);
26
26
  }
27
27
 
28
- .tier-strategic .tierCard {
28
+ .tier-certified .tierCard {
29
+ /* Cobalt-fill card carries the Conduction-issued credential treatment
30
+ for top-tier partners. */
29
31
  background: var(--c-blue-cobalt);
30
32
  border-color: var(--c-blue-cobalt);
31
33
  color: white;
32
34
  }
33
35
 
34
- .tier-certified .tierCard {
35
- /* white card, but a hairline gold stripe + the gold avatar reads it as
36
- "Conduction-issued credential" rather than just another Partner. */
36
+ .tier-service .tierCard {
37
+ /* white card, but a hairline gold stripe reads it as a service-level
38
+ SLA partner rather than a plain Host. */
37
39
  border-top: 4px solid var(--c-gold-500);
38
40
  }
39
41
 
@@ -53,8 +55,8 @@
53
55
  margin-bottom: var(--space-2);
54
56
  font-weight: 600;
55
57
  }
56
- .tier-strategic .tierLabel { color: rgba(255, 255, 255, 0.78); }
57
- .tier-certified .tierLabel { color: var(--c-gold-600, #8B6914); }
58
+ .tier-certified .tierLabel { color: rgba(255, 255, 255, 0.78); }
59
+ .tier-service .tierLabel { color: var(--c-gold-600, #8B6914); }
58
60
 
59
61
  .tierBlurb {
60
62
  font-size: 13px;
@@ -62,7 +64,7 @@
62
64
  color: var(--c-cobalt-700);
63
65
  margin: 0;
64
66
  }
65
- .tier-strategic .tierBlurb { color: rgba(255, 255, 255, 0.85); }
67
+ .tier-certified .tierBlurb { color: rgba(255, 255, 255, 0.85); }
66
68
 
67
69
  /* ---------- Generic section ---------- */
68
70
 
@@ -101,6 +103,15 @@
101
103
  border-radius: var(--radius-pill);
102
104
  font-size: 12px;
103
105
  font-family: var(--conduction-typography-font-family-code);
106
+ text-decoration: none;
107
+ transition: background 160ms, color 160ms;
108
+ }
109
+ /* Only the <a> form gets hover affordance — plain <span> chips
110
+ (apps not in the apps-registry, e.g. Nextcloud) stay static. */
111
+ a.chip:hover {
112
+ background: var(--c-blue-cobalt);
113
+ color: white;
114
+ text-decoration: none;
104
115
  }
105
116
 
106
117
  /* ---------- Solutions list ---------- */
@@ -59,3 +59,26 @@ export function getApp(slug) {
59
59
  export function getApps(slugs = []) {
60
60
  return slugs.map(getApp).filter(Boolean);
61
61
  }
62
+
63
+ /**
64
+ * Resolve a display-name (e.g. "OpenCatalogi", "DocuDesk", "MyDash")
65
+ * to its product page href, or undefined when the name is not in the
66
+ * registry. Used by partner cards / sidecards to turn the apps-shipped
67
+ * chip row into a clickable link list. Names like "Nextcloud" that
68
+ * aren't ours fall through and the consumer renders a plain span.
69
+ *
70
+ * Match is case-insensitive on both name and slug so consumers can
71
+ * pass either form ("OpenCatalogi", "opencatalogi", or "OpenCATALOGI")
72
+ * without each adding their own normalisation.
73
+ */
74
+ export function appHrefByName(name) {
75
+ if (!name) return undefined;
76
+ const target = String(name).toLowerCase();
77
+ for (const slug of APP_SLUGS) {
78
+ const entry = APPS_REGISTRY[slug];
79
+ if (slug === target || entry.name.toLowerCase() === target) {
80
+ return entry.productHref;
81
+ }
82
+ }
83
+ return undefined;
84
+ }