@flamingo-stack/openframe-frontend-core 0.0.744 → 0.0.746

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/build/export-map.json +1 -1
  2. package/dist/{chunk-LJ46XYYP.cjs → chunk-2ZVNXD74.cjs} +24 -24
  3. package/dist/{chunk-FTYQS6VI.cjs → chunk-3AUTORMB.cjs} +13 -13
  4. package/dist/{chunk-2JRCJV3M.cjs → chunk-3KVB5FQZ.cjs} +5 -5
  5. package/dist/{chunk-2UBR53AN.cjs → chunk-3OAACQQG.cjs} +4 -4
  6. package/dist/{chunk-QSO56JZ2.cjs → chunk-5X3F2KL7.cjs} +90 -90
  7. package/dist/{chunk-AT6ZI4CO.cjs → chunk-7AKFKPBV.cjs} +12 -12
  8. package/dist/{chunk-4N7KGOBS.cjs → chunk-B24U6KNJ.cjs} +3 -3
  9. package/dist/{chunk-H62FNZGG.cjs → chunk-DJ4YDVBF.cjs} +18 -18
  10. package/dist/{chunk-QZYF2YQ3.cjs → chunk-FAN73OTP.cjs} +6 -6
  11. package/dist/{chunk-HJ4KGA2M.cjs → chunk-GPSHFEF2.cjs} +1084 -890
  12. package/dist/{chunk-OC2LBQQJ.cjs → chunk-GSFANY2L.cjs} +80 -80
  13. package/dist/{chunk-URVIPSAJ.cjs → chunk-HNLI4OU5.cjs} +14 -14
  14. package/dist/{chunk-PYWHDPVC.cjs → chunk-IRKUFXVV.cjs} +38 -38
  15. package/dist/{chunk-ABLUMGCI.cjs → chunk-JHQNUWFM.cjs} +83 -33
  16. package/dist/{chunk-4A6OF4HI.cjs → chunk-KVPSV4ZI.cjs} +44 -44
  17. package/dist/{chunk-UY6A6TEY.cjs → chunk-L2IQESTQ.cjs} +2 -2
  18. package/dist/{chunk-YIOWV7LS.cjs → chunk-NZMQIAQC.cjs} +2 -2
  19. package/dist/{chunk-YJRMCFX3.cjs → chunk-O4J4NIMB.cjs} +7 -7
  20. package/dist/{chunk-GAGNKWOC.cjs → chunk-OWABNVDV.cjs} +2 -2
  21. package/dist/{chunk-HTQJYN47.cjs → chunk-PLBJ3XVC.cjs} +32 -32
  22. package/dist/{chunk-OWGLRE74.cjs → chunk-PSIKUDUV.cjs} +8 -8
  23. package/dist/{chunk-NURAL5HD.cjs → chunk-SDDZHPVK.cjs} +24 -24
  24. package/dist/{chunk-HHL23RFC.cjs → chunk-SLODGX6D.cjs} +35 -35
  25. package/dist/{chunk-7YDF3QBN.cjs → chunk-TILK7SC4.cjs} +320 -207
  26. package/dist/{chunk-U2O5YQRP.cjs → chunk-U6XWGK53.cjs} +33 -33
  27. package/dist/{chunk-JGZEHBBD.cjs → chunk-VZGCSVH7.cjs} +10 -10
  28. package/dist/{chunk-35IQLGV3.cjs → chunk-X4Y74M5K.cjs} +74 -74
  29. package/dist/{chunk-HMSUYWSB.cjs → chunk-XQJOYPPN.cjs} +4 -4
  30. package/dist/{chunk-65LJNDR2.cjs → chunk-YKF66VQY.cjs} +20 -20
  31. package/dist/{chunk-5TCIRWHL.cjs → chunk-YRULLK7N.cjs} +5 -5
  32. package/dist/{cloud-tenants-JPXAXSPE.cjs → cloud-tenants-OKUFSNPW.cjs} +13 -13
  33. package/dist/components/case-studies/index.cjs +25 -25
  34. package/dist/components/chat/index.cjs +9 -9
  35. package/dist/components/contact/index.cjs +14 -14
  36. package/dist/components/docs/index.cjs +17 -17
  37. package/dist/components/embeds/index.cjs +14 -14
  38. package/dist/components/embeds/reddit-embed-client.d.ts +3 -1
  39. package/dist/components/embeds/reddit-embed-client.d.ts.map +1 -1
  40. package/dist/components/embeds/reddit-embed-client.js +5 -3
  41. package/dist/components/embeds/twitter-embed-client.d.ts +3 -1
  42. package/dist/components/embeds/twitter-embed-client.d.ts.map +1 -1
  43. package/dist/components/embeds/twitter-embed-client.js +5 -3
  44. package/dist/components/faq/index.cjs +15 -15
  45. package/dist/components/features/index.cjs +13 -13
  46. package/dist/components/help-center-pages/index.cjs +116 -116
  47. package/dist/components/index.cjs +123 -125
  48. package/dist/components/meeting-scheduler/index.cjs +63 -63
  49. package/dist/components/navigation/index.cjs +16 -16
  50. package/dist/components/onboarding-guides/index.cjs +16 -16
  51. package/dist/components/product-demo/index.cjs +9 -9
  52. package/dist/components/related-content/index.cjs +7 -7
  53. package/dist/components/tickets/index.cjs +19 -19
  54. package/dist/components/ui/filter-checkbox-item.d.ts +8 -2
  55. package/dist/components/ui/filter-checkbox-item.d.ts.map +1 -1
  56. package/dist/components/ui/filter-checkbox-item.js +14 -4
  57. package/dist/components/ui/index.cjs +17 -13
  58. package/dist/components/ui/index.d.ts +1 -0
  59. package/dist/components/ui/index.d.ts.map +1 -1
  60. package/dist/components/ui/index.js +1 -0
  61. package/dist/components/ui/markdown/base-components.d.ts.map +1 -1
  62. package/dist/components/ui/markdown/base-components.js +3 -2
  63. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -1
  64. package/dist/components/ui/markdown/rich/embed-overrides.js +11 -8
  65. package/dist/components/ui/markdown-editor.d.ts.map +1 -1
  66. package/dist/components/ui/markdown-editor.js +4 -0
  67. package/dist/components/ui/markdown-link-command.d.ts +37 -0
  68. package/dist/components/ui/markdown-link-command.d.ts.map +1 -0
  69. package/dist/components/ui/markdown-link-command.js +180 -0
  70. package/dist/components/unified-filter-logic.js +1 -1
  71. package/dist/components/vendor-tag.d.ts +12 -0
  72. package/dist/components/vendor-tag.d.ts.map +1 -1
  73. package/dist/components/vendor-tag.js +50 -47
  74. package/dist/{devices-ONC4JOVH.cjs → devices-URCDMYFU.cjs} +14 -14
  75. package/dist/{embeddable-chat-7ZEGCDVF.cjs → embeddable-chat-B35GB5B6.cjs} +80 -80
  76. package/dist/index.cjs +36 -18
  77. package/dist/{knowledge-G7XU5YST.cjs → knowledge-5MW2PREQ.cjs} +14 -14
  78. package/dist/{logs-5B7NY4G6.cjs → logs-LJJK5CK4.cjs} +21 -21
  79. package/dist/{policies-OYR3SLJN.cjs → policies-6TCEWQYG.cjs} +17 -17
  80. package/dist/{remote-session-N4PJB5FW.cjs → remote-session-IYT4LWJC.cjs} +7 -7
  81. package/dist/{scripts-6CWHTY6U.cjs → scripts-ABB2P6PT.cjs} +20 -20
  82. package/dist/{software-update-AJPIY4SQ.cjs → software-update-4427ZRMO.cjs} +17 -17
  83. package/dist/{tickets-board-O753S4ZB.cjs → tickets-board-4MT6ZB4F.cjs} +8 -8
  84. package/dist/utils/format.d.ts.map +1 -1
  85. package/dist/utils/format.js +2 -3
  86. package/dist/utils/index.cjs +193 -28
  87. package/dist/utils/index.d.ts +2 -1
  88. package/dist/utils/index.d.ts.map +1 -1
  89. package/dist/utils/index.js +9 -6
  90. package/dist/utils/markdown-link.d.ts +44 -0
  91. package/dist/utils/markdown-link.d.ts.map +1 -0
  92. package/dist/utils/markdown-link.js +83 -0
  93. package/dist/utils/markdown-to-plain.d.ts +3 -16
  94. package/dist/utils/markdown-to-plain.d.ts.map +1 -1
  95. package/dist/utils/markdown-to-plain.js +33 -3
  96. package/dist/utils/vendor-classification.d.ts +43 -17
  97. package/dist/utils/vendor-classification.d.ts.map +1 -1
  98. package/dist/utils/vendor-classification.js +73 -22
  99. package/package.json +3 -1
  100. package/src/components/embeds/reddit-embed-client.tsx +7 -3
  101. package/src/components/embeds/twitter-embed-client.tsx +7 -3
  102. package/src/components/ui/filter-checkbox-item.tsx +21 -5
  103. package/src/components/ui/index.ts +1 -0
  104. package/src/components/ui/markdown/base-components.tsx +5 -2
  105. package/src/components/ui/markdown/rich/embed-overrides.tsx +12 -9
  106. package/src/components/ui/markdown-editor.tsx +6 -0
  107. package/src/components/ui/markdown-link-command.tsx +252 -0
  108. package/src/components/unified-filter-logic.tsx +1 -1
  109. package/src/components/vendor-tag.tsx +73 -69
  110. package/src/utils/format.ts +2 -3
  111. package/src/utils/index.ts +8 -5
  112. package/src/utils/markdown-link.ts +148 -0
  113. package/src/utils/markdown-to-plain.ts +64 -22
  114. package/src/utils/vendor-classification.ts +113 -34
  115. package/dist/utils/format-text-stub.d.ts +0 -5
  116. package/dist/utils/format-text-stub.d.ts.map +0 -1
  117. package/dist/utils/format-text-stub.js +0 -20
  118. package/src/utils/format-text-stub.ts +0 -17
@@ -0,0 +1,252 @@
1
+ 'use client';
2
+
3
+ import type { ICommand, TextAreaTextApi, TextRange } from '@uiw/react-md-editor';
4
+ import { type ReactNode, useEffect, useId, useMemo, useState } from 'react';
5
+ import {
6
+ type AuthoredLinkRel,
7
+ findMarkdownLinkAt,
8
+ formatMarkdownLink,
9
+ type MarkdownLinkFields,
10
+ } from '../../utils/markdown-link';
11
+ import { Button } from './button';
12
+ import { Input } from './input';
13
+ import { ModalV2, ModalV2Footer, ModalV2Header, ModalV2Title } from './modal-v2';
14
+ import { TabSelector } from './tab-selector';
15
+
16
+ type LinkRelChoice = 'follow' | AuthoredLinkRel;
17
+
18
+ /** What each choice tells search engines, in Google's own terms (Search Central, "Qualify your outbound links"). */
19
+ const LINK_REL_CHOICES: { id: LinkRelChoice; label: string; hint: string }[] = [
20
+ { id: 'follow', label: 'Follow', hint: 'Search engines follow this link. This is the default.' },
21
+ {
22
+ id: 'nofollow',
23
+ label: 'Nofollow',
24
+ hint: 'Search engines are asked not to follow this link (rel="nofollow").',
25
+ },
26
+ {
27
+ id: 'sponsored',
28
+ label: 'Sponsored',
29
+ hint: 'For ads, paid placements and sponsorships (rel="sponsored").',
30
+ },
31
+ { id: 'ugc', label: 'UGC', hint: 'For links in content written by users, such as comments (rel="ugc").' },
32
+ ];
33
+
34
+ const LINK_REL_ITEMS = LINK_REL_CHOICES.map(({ id, label }) => ({ id, label }));
35
+
36
+ /** The choice a link's `rel` shows: the most specific value it carries (sponsored, then ugc, then nofollow). */
37
+ function choiceOf(rel: readonly AuthoredLinkRel[]): LinkRelChoice {
38
+ if (rel.includes('sponsored')) return 'sponsored';
39
+ if (rel.includes('ugc')) return 'ugc';
40
+ if (rel.includes('nofollow')) return 'nofollow';
41
+ return 'follow';
42
+ }
43
+
44
+ export interface MarkdownLinkDialogProps {
45
+ isOpen: boolean;
46
+ /** The link being edited, or the selection a new link starts from. */
47
+ initial: MarkdownLinkFields;
48
+ /** True when `initial` is a link already in the text: the dialog then offers to remove it. */
49
+ editing: boolean;
50
+ onSubmit: (link: MarkdownLinkFields) => void;
51
+ /** Replaces the link with its text. */
52
+ onRemove: () => void;
53
+ onClose: () => void;
54
+ }
55
+
56
+ /**
57
+ * The link dialog of the markdown editors: the link's text, its address, and what it tells search
58
+ * engines: Follow (the default), Nofollow, Sponsored or UGC. Any choice but Follow writes the link
59
+ * as `<a rel="…">`. A link that carries several values (`rel="ugc nofollow"`) keeps them all until
60
+ * the choice is changed.
61
+ */
62
+ export function MarkdownLinkDialog({ isOpen, initial, editing, onSubmit, onRemove, onClose }: MarkdownLinkDialogProps) {
63
+ const formId = useId();
64
+ const [text, setText] = useState(initial.text);
65
+ const [url, setUrl] = useState(initial.url);
66
+ const [rel, setRel] = useState(initial.rel);
67
+
68
+ // Re-seed while rendering when the dialog opens on another link (the modal stays mounted).
69
+ const [seededWith, setSeededWith] = useState({ isOpen, initial });
70
+ if (seededWith.isOpen !== isOpen || seededWith.initial !== initial) {
71
+ setSeededWith({ isOpen, initial });
72
+ if (isOpen) {
73
+ setText(initial.text);
74
+ setUrl(initial.url);
75
+ setRel(initial.rel);
76
+ }
77
+ }
78
+
79
+ const choice = choiceOf(rel);
80
+ const trimmedUrl = url.trim();
81
+ const submit = () => {
82
+ if (!trimmedUrl) return;
83
+ onSubmit({ text: text.trim(), url: trimmedUrl, rel });
84
+ };
85
+
86
+ return (
87
+ <ModalV2 isOpen={isOpen} onClose={onClose} className="text-left md:max-w-[560px]">
88
+ <ModalV2Header>
89
+ <ModalV2Title>{editing ? 'Edit link' : 'Add link'}</ModalV2Title>
90
+ </ModalV2Header>
91
+
92
+ <form
93
+ id={formId}
94
+ className="flex w-full flex-col gap-[var(--spacing-system-l)]"
95
+ onSubmit={event => {
96
+ event.preventDefault();
97
+ submit();
98
+ }}
99
+ >
100
+ <Input label="Text" value={text} onChange={event => setText(event.target.value)} placeholder="Link text" />
101
+ <Input
102
+ label="URL"
103
+ value={url}
104
+ onChange={event => setUrl(event.target.value)}
105
+ placeholder="https://"
106
+ autoFocus
107
+ inputMode="url"
108
+ />
109
+ <div className="flex flex-col gap-[var(--spacing-system-xsf)]">
110
+ <TabSelector
111
+ label="Search engines"
112
+ value={choice}
113
+ onValueChange={value => {
114
+ const next = LINK_REL_CHOICES.find(option => option.id === value)?.id ?? 'follow';
115
+ setRel(next === 'follow' ? [] : [next]);
116
+ }}
117
+ items={LINK_REL_ITEMS}
118
+ variant="secondary"
119
+ />
120
+ <p className="text-ods-text-secondary text-h6">
121
+ {LINK_REL_CHOICES.find(option => option.id === choice)?.hint}
122
+ </p>
123
+ </div>
124
+ </form>
125
+
126
+ <ModalV2Footer>
127
+ {editing ? (
128
+ <Button type="button" variant="outline" onClick={onRemove} className="flex-1">
129
+ Remove link
130
+ </Button>
131
+ ) : (
132
+ <Button type="button" variant="outline" onClick={onClose} className="flex-1">
133
+ Cancel
134
+ </Button>
135
+ )}
136
+ {/* A submit button of the form (by `form`), so Enter in either field saves the link. */}
137
+ <Button type="submit" form={formId} variant="accent" disabled={!trimmedUrl} className="flex-1">
138
+ {editing ? 'Save' : 'Add link'}
139
+ </Button>
140
+ </ModalV2Footer>
141
+ </ModalV2>
142
+ );
143
+ }
144
+
145
+ interface PendingLink {
146
+ api: TextAreaTextApi;
147
+ range: TextRange;
148
+ initial: MarkdownLinkFields;
149
+ editing: boolean;
150
+ }
151
+
152
+ const EMPTY_LINK: MarkdownLinkFields = { text: '', url: '', rel: [] };
153
+
154
+ const LOOKS_LIKE_URL = /^(?:https?:\/\/|www\.|mailto:|\/|#)\S*$/i;
155
+
156
+ function initialFieldsFromSelection(selectedText: string): MarkdownLinkFields {
157
+ const selected = selectedText.trim();
158
+ return LOOKS_LIKE_URL.test(selected) ? { text: '', url: selected, rel: [] } : { text: selected, url: '', rel: [] };
159
+ }
160
+
161
+ export interface MarkdownLinkCommand {
162
+ /**
163
+ * The editor's toolbar: the vendor's commands with its `link` command replaced by this dialog.
164
+ * `undefined` until the vendor's commands have loaded (the editor then shows its own defaults).
165
+ */
166
+ commands: ICommand[] | undefined;
167
+ /** The dialog. Render it beside the editor. */
168
+ linkDialog: ReactNode;
169
+ }
170
+
171
+ /**
172
+ * The link command every markdown editor uses: the toolbar's link button and Ctrl/Cmd+L open
173
+ * {@link MarkdownLinkDialog} on the link under the caret (markdown or HTML), or on the selection
174
+ * for a new link, and write the result back as one undoable edit.
175
+ */
176
+ export function useMarkdownLinkCommand(): MarkdownLinkCommand {
177
+ const [vendorCommands, setVendorCommands] = useState<ICommand[] | undefined>(undefined);
178
+ const [pending, setPending] = useState<PendingLink | null>(null);
179
+
180
+ useEffect(() => {
181
+ let cancelled = false;
182
+ void (async () => {
183
+ try {
184
+ const mod = await import('@uiw/react-md-editor');
185
+ if (!cancelled && mod.commands?.getCommands) setVendorCommands(mod.commands.getCommands());
186
+ } catch (error) {
187
+ // Chunk load failure: the editor keeps its own default toolbar.
188
+ console.error('Failed to load markdown editor toolbar commands:', error);
189
+ }
190
+ })();
191
+ return () => {
192
+ cancelled = true;
193
+ };
194
+ }, []);
195
+
196
+ const commands = useMemo(() => {
197
+ if (!vendorCommands) return undefined;
198
+ return vendorCommands.map(command => {
199
+ if (command.name !== 'link') return command;
200
+ const linkCommand: ICommand = {
201
+ name: 'link',
202
+ keyCommand: 'link',
203
+ shortcuts: 'ctrlcmd+l',
204
+ buttonProps: { 'aria-label': 'Add or edit a link (ctrl + l)', title: 'Add or edit a link (ctrl + l)' },
205
+ icon: command.icon,
206
+ execute: (state, api) => {
207
+ const existing = findMarkdownLinkAt(state.text, state.selection.start, state.selection.end);
208
+ setPending(
209
+ existing
210
+ ? {
211
+ api,
212
+ range: { start: existing.start, end: existing.end },
213
+ initial: { text: existing.text, url: existing.url, rel: existing.rel },
214
+ editing: true,
215
+ }
216
+ : {
217
+ api,
218
+ range: state.selection,
219
+ initial: initialFieldsFromSelection(state.selectedText),
220
+ editing: false,
221
+ },
222
+ );
223
+ },
224
+ };
225
+ return linkCommand;
226
+ });
227
+ }, [vendorCommands]);
228
+
229
+ // Writes over the link's range as ONE edit through the editor's own text API, so Undo restores it.
230
+ const write = (current: PendingLink, text: string) => {
231
+ setPending(null);
232
+ current.api.setSelectionRange(current.range);
233
+ current.api.replaceSelection(text);
234
+ };
235
+
236
+ const linkDialog = (
237
+ <MarkdownLinkDialog
238
+ isOpen={pending !== null}
239
+ initial={pending?.initial ?? EMPTY_LINK}
240
+ editing={pending?.editing ?? false}
241
+ onSubmit={link => {
242
+ if (pending) write(pending, formatMarkdownLink(link));
243
+ }}
244
+ onRemove={() => {
245
+ if (pending) write(pending, pending.initial.text);
246
+ }}
247
+ onClose={() => setPending(null)}
248
+ />
249
+ );
250
+
251
+ return { commands, linkDialog };
252
+ }
@@ -397,7 +397,7 @@ export function useUnifiedFiltering(config: FilterConfig) {
397
397
  };
398
398
  }
399
399
 
400
- import { formatClassification, formatPricingModel } from '../utils/format-text-stub';
400
+ import { formatClassification, formatPricingModel } from '../utils/format';
401
401
 
402
402
  // Helper functions for filter labels
403
403
  function getFilterLabel(filterKey: string): string {
@@ -1,13 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { Boxes, Code, Database, DollarSign, Hand, Plug, ShieldCheck, Sparkles } from 'lucide-react';
3
+ import { Boxes, Database, DollarSign, Hand, Plug, ShieldCheck, Sparkles } from 'lucide-react';
4
+ import { createElement, type ReactNode } from 'react';
4
5
  import { cn } from '../utils/cn';
5
- import {
6
- OPENFRAME_CLASSIFICATIONS,
7
- isOpenFrameClassification,
8
- type OpenFrameClassification,
9
- } from '../utils/vendor-classification';
10
- import { OpenframeLogoIcon } from './icons-v2-generated/logos/openframe-logo-icon';
6
+ import { formatClassification } from '../utils/format';
7
+ import { type VendorClassificationIconConfig, vendorClassificationCopy } from '../utils/vendor-classification';
8
+ import { resolveIcon } from './chat/utils/icon-library';
11
9
 
12
10
  export interface VendorTagProps {
13
11
  type:
@@ -36,12 +34,69 @@ export interface VendorTagProps {
36
34
  accentColor?: string;
37
35
  }
38
36
 
39
- /** An OpenFrame classification: the OpenFrame mark and its one word ("Selected", "Connect"). */
40
- function openFrameTag(classification: OpenFrameClassification, text?: string) {
37
+ const TONE_CLASS: Readonly<Record<VendorClassificationIconConfig['tone'], string>> = {
38
+ accent: 'text-ods-text-on-accent',
39
+ neutral: 'text-ods-text-secondary',
40
+ warning: 'text-ods-warning',
41
+ };
42
+
43
+ /**
44
+ * A classification's icon, from its config (`VENDOR_CLASSIFICATION_COPY[value].icon`): the
45
+ * icon is resolved by NAME through the shared resolver, never imported here. A license icon
46
+ * sits on a small tile (the accent tile for an `accent` tone); an OpenFrame mark and a
47
+ * lifecycle icon are drawn bare in their tone. A value with no icon draws nothing.
48
+ */
49
+ export function VendorClassificationIcon({
50
+ classification,
51
+ accentColor,
52
+ className,
53
+ }: {
54
+ classification: string;
55
+ /** The accent tile's colour, for a host that tints its hover accent (a vendor card). */
56
+ accentColor?: string;
57
+ className?: string;
58
+ }) {
59
+ const copy = vendorClassificationCopy(classification);
60
+ if (!copy?.icon) return null;
61
+ // The resolver returns one component per name, loaded on demand (drawn through `createElement`, as `NamedIcon` does).
62
+ const icon = resolveIcon(copy.icon.name);
63
+ if (copy.group !== 'license') {
64
+ const bareClass = copy.icon.tone === 'accent' ? 'text-ods-accent' : TONE_CLASS[copy.icon.tone];
65
+ return createElement(icon, {
66
+ size: copy.group === 'openframe' ? 16 : 12,
67
+ className: cn('shrink-0', bareClass, className),
68
+ });
69
+ }
70
+ const accent = copy.icon.tone === 'accent';
71
+ return (
72
+ <div
73
+ className={cn(
74
+ 'flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm',
75
+ accent ? 'bg-ods-accent' : 'bg-ods-border',
76
+ className,
77
+ )}
78
+ style={accent && accentColor ? { backgroundColor: accentColor } : undefined}
79
+ >
80
+ {createElement(icon, { size: 10, className: TONE_CLASS[copy.icon.tone] })}
81
+ </div>
82
+ );
83
+ }
84
+
85
+ /**
86
+ * A classification tag, derived from the value the server sends and its config: the label
87
+ * (an OpenFrame value's one word), the configured icon, and the warning text colour for a
88
+ * lifecycle value. Any other value is its title-cased label with no icon.
89
+ */
90
+ function classificationTag(
91
+ value: string,
92
+ text?: string,
93
+ accentColor?: string,
94
+ ): { text: string; icon: ReactNode; textColor?: string } {
95
+ const copy = vendorClassificationCopy(value);
41
96
  return {
42
- text: text || OPENFRAME_CLASSIFICATIONS[classification].word,
43
- textColor: undefined,
44
- icon: <OpenframeLogoIcon secondaryColor="currentColor" color="currentColor" className="h-4 w-4 text-ods-accent" />,
97
+ text: text || (copy?.group === 'openframe' ? copy.word : undefined) || copy?.label || formatClassification(value),
98
+ textColor: copy?.group === 'lifecycle' ? 'text-ods-warning' : undefined,
99
+ icon: <VendorClassificationIcon classification={value} accentColor={accentColor} />,
45
100
  };
46
101
  }
47
102
 
@@ -53,7 +108,7 @@ export function VendorTag({ type, text, className = '', hidden = false, size = '
53
108
  );
54
109
 
55
110
  // Get display text and styling based on type
56
- const getTagContent = () => {
111
+ const getTagContent = (): { text: string; icon: ReactNode; textColor?: string } => {
57
112
  switch (type) {
58
113
  case 'placeholder':
59
114
  return {
@@ -86,26 +141,9 @@ export function VendorTag({ type, text, className = '', hidden = false, size = '
86
141
  ),
87
142
  };
88
143
  case 'open-source':
89
- return {
90
- text: text || 'Open Source',
91
- icon: (
92
- <div
93
- className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm bg-ods-accent"
94
- style={accentColor ? { backgroundColor: accentColor } : undefined}
95
- >
96
- <Code width={10} height={10} className="text-ods-text-on-accent" />
97
- </div>
98
- ),
99
- };
144
+ return classificationTag('open_source', text, accentColor);
100
145
  case 'commercial':
101
- return {
102
- text: text || 'Commercial Vendor',
103
- icon: (
104
- <div className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm bg-ods-border">
105
- <DollarSign width={10} height={10} className="text-ods-text-secondary" />
106
- </div>
107
- ),
108
- };
146
+ return classificationTag('commercial', text);
109
147
  // platform-capability tags — same neutral icon-box chrome as 'commercial'
110
148
  case 'api':
111
149
  return {
@@ -188,46 +226,12 @@ export function VendorTag({ type, text, className = '', hidden = false, size = '
188
226
  </div>
189
227
  ),
190
228
  };
191
- case 'classification': {
192
- // Handle specific classification types based on the text value
193
- const classificationType = text?.toLowerCase();
194
-
195
- if (classificationType === 'open_source') {
196
- return {
197
- text: 'Open Source',
198
- icon: (
199
- <div className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm bg-ods-accent">
200
- <Code width={10} height={10} className="text-ods-text-on-accent" />
201
- </div>
202
- ),
203
- };
204
- } else if (classificationType === 'commercial') {
205
- return {
206
- text: 'Commercial Vendor',
207
- icon: (
208
- <div className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm bg-ods-border">
209
- <DollarSign width={10} height={10} className="text-ods-text-secondary" />
210
- </div>
211
- ),
212
- };
213
- } else if (isOpenFrameClassification(classificationType)) {
214
- return openFrameTag(classificationType);
215
- } else {
216
- // Fallback for unknown classification types
217
- return {
218
- text: text || 'Classification',
219
- icon: (
220
- <div className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm bg-ods-accent">
221
- <span className="text-[8px] font-bold text-ods-text-on-accent">C</span>
222
- </div>
223
- ),
224
- };
225
- }
226
- }
229
+ case 'classification':
230
+ return classificationTag((text ?? '').toLowerCase(), undefined, accentColor);
227
231
  case 'openframe_selected':
228
232
  case 'openframe_connect':
229
233
  case 'openframe_shipped':
230
- return openFrameTag(type, text);
234
+ return classificationTag(type, text);
231
235
  default:
232
236
  return {
233
237
  text: text || type,
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { ProgramInstant } from './program-instant';
6
- import { OPENFRAME_CLASSIFICATIONS, isOpenFrameClassification } from './vendor-classification';
6
+ import { vendorClassificationCopy } from './vendor-classification';
7
7
 
8
8
  /**
9
9
  * Format a date to a human-readable string
@@ -941,8 +941,7 @@ export function stripHtml(html: string): string {
941
941
  * curated mapping.
942
942
  */
943
943
  export function formatClassification(classification: string): string {
944
- if (isOpenFrameClassification(classification)) return OPENFRAME_CLASSIFICATIONS[classification].label;
945
- return formatUnderscoreText(classification);
944
+ return vendorClassificationCopy(classification)?.label ?? formatUnderscoreText(classification);
946
945
  }
947
946
 
948
947
  /**
@@ -21,13 +21,15 @@ export * from './ai-providers';
21
21
  export * from './model-token-rates';
22
22
  export { TRIAL_TERMS, trialLabels, trialLengthLabel } from './trial';
23
23
  export {
24
- VENDOR_CLASSIFICATIONS,
25
- OPENFRAME_CLASSIFICATIONS,
26
- isVendorClassification,
24
+ VENDOR_CLASSIFICATION_COPY,
25
+ vendorClassificationCopy,
26
+ vendorClassificationGroup,
27
+ compareVendorClassifications,
27
28
  isOpenFrameClassification,
28
29
  type VendorClassificationValue,
29
- type OpenFrameClassification,
30
- type OpenFrameClassificationCopy,
30
+ type VendorClassificationGroup,
31
+ type VendorClassificationCopy,
32
+ type VendorClassificationIconConfig,
31
33
  } from './vendor-classification';
32
34
  // SVG path constants — re-exported here (server-safe) because icons-v2 has "use client"
33
35
  export { PLAY_ICON_PATH } from '../components/icons-v2-generated/media-playback/play-icon';
@@ -424,6 +426,7 @@ export * from './doc-path-utils';
424
426
  export * from './doc-tree-nav';
425
427
  export * from './tree-builder';
426
428
  export * from './markdown-heading-id';
429
+ export * from './markdown-link';
427
430
  export * from './markdown-section-extractor';
428
431
  export * from './markdown-to-plain';
429
432
  export * from './embed-url-converters';
@@ -0,0 +1,148 @@
1
+ /**
2
+ * One link format for the markdown editors and one `rel` rule for the renderers.
3
+ *
4
+ * A FOLLOWED link is written as markdown, `[text](url)`, exactly as the editors always wrote it.
5
+ * A link with an authored `rel` (nofollow, sponsored or ugc, the values Google reads) is written as
6
+ * an HTML anchor, `<a href="url" rel="sponsored">text</a>`: markdown has no attribute syntax, and
7
+ * an HTML anchor is what stored content already uses for links that carry attributes (the
8
+ * sanitizer keeps `rel` on `a`). Reading back accepts both forms, so a link can be switched either
9
+ * way. Nothing is rewritten unless an editor changes a link.
10
+ *
11
+ * Server-safe: no React, no DOM.
12
+ */
13
+
14
+ /** The `rel` tokens an author may set on a link. Search engines read these; nothing else is kept. */
15
+ export const AUTHORED_LINK_REL_TOKENS = ['nofollow', 'ugc', 'sponsored'] as const;
16
+
17
+ export type AuthoredLinkRel = (typeof AUTHORED_LINK_REL_TOKENS)[number];
18
+
19
+ /** What a renderer adds to every link that opens in a new tab. */
20
+ const NEW_TAB_REL = 'noopener noreferrer';
21
+
22
+ /** The authored SEO tokens in a `rel` value, in their canonical order. */
23
+ export function authoredRelTokens(rel: string | readonly string[] | null | undefined): AuthoredLinkRel[] {
24
+ if (!rel) return [];
25
+ const given = new Set(
26
+ (typeof rel === 'string' ? rel.split(/\s+/) : rel).map(token => token.trim().toLowerCase()).filter(Boolean),
27
+ );
28
+ return AUTHORED_LINK_REL_TOKENS.filter(token => given.has(token));
29
+ }
30
+
31
+ /** Whether an authored `rel` asks search engines not to follow the link. */
32
+ export function isNofollowRel(rel: string | readonly string[] | null | undefined): boolean {
33
+ return authoredRelTokens(rel).includes('nofollow');
34
+ }
35
+
36
+ /**
37
+ * The `rel` a renderer writes for a link: the author's SEO tokens, plus `noopener noreferrer` when
38
+ * the link opens in a new tab. `undefined` when there is nothing to write.
39
+ */
40
+ export function renderedLinkRel(
41
+ authoredRel: string | readonly string[] | null | undefined,
42
+ opensInNewTab: boolean,
43
+ ): string | undefined {
44
+ const tokens: string[] = authoredRelTokens(authoredRel);
45
+ if (opensInNewTab) tokens.push(NEW_TAB_REL);
46
+ return tokens.length > 0 ? tokens.join(' ') : undefined;
47
+ }
48
+
49
+ /** A link as the editor's dialog edits it. */
50
+ export interface MarkdownLinkFields {
51
+ text: string;
52
+ url: string;
53
+ /** The authored `rel` values. Empty = a followed link. */
54
+ rel: AuthoredLinkRel[];
55
+ }
56
+
57
+ /** A link found in the editor's text, with the range it occupies. */
58
+ export interface MarkdownLinkMatch extends MarkdownLinkFields {
59
+ start: number;
60
+ end: number;
61
+ }
62
+
63
+ // `[text](url)` or `[text](url "title")`, never an image (`![…]`). The text may hold escaped brackets.
64
+ const MARKDOWN_LINK_RE = /(?<!!)\[((?:\\.|[^\]\\])*)\]\(\s*<?([^\s)>]*)>?(?:\s+"[^"]*")?\s*\)/g;
65
+ // One HTML anchor, attributes and text. Nested anchors are not valid HTML, so the first `</a>` ends it.
66
+ const HTML_LINK_RE = /<a\s([^>]*)>([\s\S]*?)<\/a\s*>/gi;
67
+
68
+ function htmlAttribute(attributes: string, name: string): string | null {
69
+ const match = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i').exec(attributes);
70
+ if (!match) return null;
71
+ return match[1] ?? match[2] ?? match[3] ?? '';
72
+ }
73
+
74
+ function decodeHtmlEntities(value: string): string {
75
+ return value
76
+ .replace(/&quot;/g, '"')
77
+ .replace(/&#39;/g, "'")
78
+ .replace(/&lt;/g, '<')
79
+ .replace(/&gt;/g, '>')
80
+ .replace(/&amp;/g, '&');
81
+ }
82
+
83
+ function escapeHtmlAttribute(value: string): string {
84
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
85
+ }
86
+
87
+ function escapeHtmlText(value: string): string {
88
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
89
+ }
90
+
91
+ /** Brackets end the link text and `<` would open raw HTML (CommonMark), so all three are escaped. */
92
+ function escapeMarkdownLinkText(value: string): string {
93
+ return value.replace(/([\\[\]<])/g, '\\$1');
94
+ }
95
+
96
+ function unescapeMarkdownLinkText(value: string): string {
97
+ return value.replace(/\\([\\[\]<])/g, '$1');
98
+ }
99
+
100
+ /** A URL inside `(…)` breaks on whitespace and unbalanced parentheses; percent-encode them. */
101
+ function encodeMarkdownUrl(url: string): string {
102
+ return url.replace(/\s/g, match => encodeURIComponent(match)).replace(/[()]/g, match => encodeURIComponent(match));
103
+ }
104
+
105
+ /**
106
+ * The link the selection `[selectionStart, selectionEnd]` is in or on, if any: a markdown link or an
107
+ * HTML anchor that contains the whole selection (a caret counts when it touches the link).
108
+ */
109
+ export function findMarkdownLinkAt(
110
+ text: string,
111
+ selectionStart: number,
112
+ selectionEnd: number = selectionStart,
113
+ ): MarkdownLinkMatch | null {
114
+ const from = Math.min(selectionStart, selectionEnd);
115
+ const to = Math.max(selectionStart, selectionEnd);
116
+ const contains = (start: number, end: number) => start <= from && to <= end;
117
+
118
+ for (const match of text.matchAll(HTML_LINK_RE)) {
119
+ const start = match.index ?? 0;
120
+ const end = start + match[0].length;
121
+ if (!contains(start, end)) continue;
122
+ const attributes = match[1] ?? '';
123
+ return {
124
+ start,
125
+ end,
126
+ text: decodeHtmlEntities(match[2] ?? ''),
127
+ url: decodeHtmlEntities(htmlAttribute(attributes, 'href') ?? ''),
128
+ rel: authoredRelTokens(htmlAttribute(attributes, 'rel')),
129
+ };
130
+ }
131
+ for (const match of text.matchAll(MARKDOWN_LINK_RE)) {
132
+ const start = match.index ?? 0;
133
+ const end = start + match[0].length;
134
+ if (!contains(start, end)) continue;
135
+ return { start, end, text: unescapeMarkdownLinkText(match[1] ?? ''), url: match[2] ?? '', rel: [] };
136
+ }
137
+ return null;
138
+ }
139
+
140
+ /** The text that writes a link: markdown when followed, an HTML anchor when it carries a `rel`. */
141
+ export function formatMarkdownLink({ text, url, rel }: MarkdownLinkFields): string {
142
+ const label = text.length > 0 ? text : url;
143
+ const tokens = authoredRelTokens(rel);
144
+ if (tokens.length > 0) {
145
+ return `<a href="${escapeHtmlAttribute(url)}" rel="${tokens.join(' ')}">${escapeHtmlText(label)}</a>`;
146
+ }
147
+ return `[${escapeMarkdownLinkText(label)}](${encodeMarkdownUrl(url)})`;
148
+ }