@flamingo-stack/openframe-frontend-core 0.0.744 → 0.0.747
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/build/export-map.json +1 -1
- package/dist/{chunk-HTQJYN47.cjs → chunk-2CEQGGRN.cjs} +32 -32
- package/dist/{chunk-PYWHDPVC.cjs → chunk-4AWI6CCU.cjs} +38 -38
- package/dist/{chunk-FTYQS6VI.cjs → chunk-4DT5YXTM.cjs} +13 -13
- package/dist/{chunk-YJRMCFX3.cjs → chunk-4MGZYY6U.cjs} +7 -7
- package/dist/{chunk-HMSUYWSB.cjs → chunk-6RTHHU3S.cjs} +4 -4
- package/dist/{chunk-7YDF3QBN.cjs → chunk-7Y5U3Y4S.cjs} +320 -207
- package/dist/{chunk-GAGNKWOC.cjs → chunk-BBM2ICOY.cjs} +2 -2
- package/dist/{chunk-4N7KGOBS.cjs → chunk-BEC43GHL.cjs} +3 -3
- package/dist/{chunk-OC2LBQQJ.cjs → chunk-C7NTL2GV.cjs} +80 -80
- package/dist/{chunk-QZYF2YQ3.cjs → chunk-FCQCEQBY.cjs} +6 -6
- package/dist/{chunk-URVIPSAJ.cjs → chunk-HNLI4OU5.cjs} +14 -14
- package/dist/{chunk-2UBR53AN.cjs → chunk-HTMHDEIV.cjs} +4 -4
- package/dist/{chunk-NURAL5HD.cjs → chunk-IJA3OZWF.cjs} +24 -24
- package/dist/{chunk-ABLUMGCI.cjs → chunk-JHQNUWFM.cjs} +83 -33
- package/dist/{chunk-QSO56JZ2.cjs → chunk-JPSMPXCH.cjs} +90 -90
- package/dist/{chunk-UY6A6TEY.cjs → chunk-L2IQESTQ.cjs} +2 -2
- package/dist/{chunk-AT6ZI4CO.cjs → chunk-LLAKQT3B.cjs} +12 -12
- package/dist/{chunk-HHL23RFC.cjs → chunk-MA7T36JY.cjs} +35 -35
- package/dist/{chunk-H62FNZGG.cjs → chunk-O6QQ44I3.cjs} +18 -18
- package/dist/{chunk-LJ46XYYP.cjs → chunk-P7KTAXXE.cjs} +41 -27
- package/dist/{chunk-2JRCJV3M.cjs → chunk-PIQRV4EQ.cjs} +5 -5
- package/dist/{chunk-OWGLRE74.cjs → chunk-PSIKUDUV.cjs} +8 -8
- package/dist/{chunk-4A6OF4HI.cjs → chunk-PSSGIZXZ.cjs} +44 -44
- package/dist/{chunk-YIOWV7LS.cjs → chunk-QPSQ5WLG.cjs} +2 -2
- package/dist/{chunk-HJ4KGA2M.cjs → chunk-R3FXJXSQ.cjs} +1226 -1012
- package/dist/{chunk-65LJNDR2.cjs → chunk-TYW7NBP7.cjs} +20 -20
- package/dist/{chunk-U2O5YQRP.cjs → chunk-U6XWGK53.cjs} +33 -33
- package/dist/{chunk-35IQLGV3.cjs → chunk-WY4FKKCV.cjs} +74 -74
- package/dist/{chunk-JGZEHBBD.cjs → chunk-YIRX6C7V.cjs} +10 -10
- package/dist/{chunk-5TCIRWHL.cjs → chunk-YRULLK7N.cjs} +5 -5
- package/dist/{cloud-tenants-JPXAXSPE.cjs → cloud-tenants-QJZIF2JS.cjs} +13 -13
- package/dist/components/case-studies/index.cjs +31 -29
- package/dist/components/case-studies/share-experience-section.d.ts.map +1 -1
- package/dist/components/case-studies/share-experience-section.js +6 -5
- package/dist/components/chat/index.cjs +9 -9
- package/dist/components/contact/index.cjs +14 -14
- package/dist/components/docs/index.cjs +17 -17
- package/dist/components/embeds/index.cjs +14 -14
- package/dist/components/embeds/reddit-embed-client.d.ts +3 -1
- package/dist/components/embeds/reddit-embed-client.d.ts.map +1 -1
- package/dist/components/embeds/reddit-embed-client.js +5 -3
- package/dist/components/embeds/twitter-embed-client.d.ts +3 -1
- package/dist/components/embeds/twitter-embed-client.d.ts.map +1 -1
- package/dist/components/embeds/twitter-embed-client.js +5 -3
- package/dist/components/faq/index.cjs +15 -15
- package/dist/components/features/index.cjs +13 -13
- package/dist/components/help-center-pages/index.cjs +116 -116
- package/dist/components/index.cjs +125 -125
- package/dist/components/layout/page-heading.d.ts +16 -0
- package/dist/components/layout/page-heading.d.ts.map +1 -1
- package/dist/components/layout/page-heading.js +16 -2
- package/dist/components/meeting-scheduler/index.cjs +63 -63
- package/dist/components/navigation/index.cjs +16 -16
- package/dist/components/onboarding-guides/index.cjs +16 -16
- package/dist/components/product-demo/index.cjs +9 -9
- package/dist/components/related-content/index.cjs +7 -7
- package/dist/components/tickets/index.cjs +19 -19
- package/dist/components/ui/filter-checkbox-item.d.ts +8 -2
- package/dist/components/ui/filter-checkbox-item.d.ts.map +1 -1
- package/dist/components/ui/filter-checkbox-item.js +14 -4
- package/dist/components/ui/index.cjs +19 -13
- package/dist/components/ui/index.d.ts +1 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +1 -0
- package/dist/components/ui/layout-hold.d.ts +4 -3
- package/dist/components/ui/layout-hold.d.ts.map +1 -1
- package/dist/components/ui/layout-hold.js +29 -9
- package/dist/components/ui/markdown/base-components.d.ts.map +1 -1
- package/dist/components/ui/markdown/base-components.js +3 -2
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -1
- package/dist/components/ui/markdown/rich/embed-overrides.js +11 -8
- package/dist/components/ui/markdown-editor.d.ts.map +1 -1
- package/dist/components/ui/markdown-editor.js +4 -0
- package/dist/components/ui/markdown-link-command.d.ts +37 -0
- package/dist/components/ui/markdown-link-command.d.ts.map +1 -0
- package/dist/components/ui/markdown-link-command.js +180 -0
- package/dist/components/unified-filter-logic.js +1 -1
- package/dist/components/vendor-tag.d.ts +12 -0
- package/dist/components/vendor-tag.d.ts.map +1 -1
- package/dist/components/vendor-tag.js +50 -47
- package/dist/{devices-ONC4JOVH.cjs → devices-V55KQCAL.cjs} +14 -14
- package/dist/{embeddable-chat-7ZEGCDVF.cjs → embeddable-chat-5CDR4I6E.cjs} +80 -80
- package/dist/index.cjs +38 -18
- package/dist/{knowledge-G7XU5YST.cjs → knowledge-S3VGDRKR.cjs} +14 -14
- package/dist/{logs-5B7NY4G6.cjs → logs-K5E2BGZE.cjs} +21 -21
- package/dist/{policies-OYR3SLJN.cjs → policies-BWU4FRFE.cjs} +17 -17
- package/dist/{remote-session-N4PJB5FW.cjs → remote-session-IYT4LWJC.cjs} +7 -7
- package/dist/{scripts-6CWHTY6U.cjs → scripts-25UPGXZU.cjs} +20 -20
- package/dist/{software-update-AJPIY4SQ.cjs → software-update-F6UC7ULZ.cjs} +17 -17
- package/dist/{tickets-board-O753S4ZB.cjs → tickets-board-4MT6ZB4F.cjs} +8 -8
- package/dist/utils/format.d.ts.map +1 -1
- package/dist/utils/format.js +2 -3
- package/dist/utils/index.cjs +193 -28
- package/dist/utils/index.d.ts +2 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +9 -6
- package/dist/utils/markdown-link.d.ts +44 -0
- package/dist/utils/markdown-link.d.ts.map +1 -0
- package/dist/utils/markdown-link.js +83 -0
- package/dist/utils/markdown-to-plain.d.ts +3 -16
- package/dist/utils/markdown-to-plain.d.ts.map +1 -1
- package/dist/utils/markdown-to-plain.js +33 -3
- package/dist/utils/vendor-classification.d.ts +43 -17
- package/dist/utils/vendor-classification.d.ts.map +1 -1
- package/dist/utils/vendor-classification.js +73 -22
- package/package.json +3 -1
- package/src/components/case-studies/share-experience-section.tsx +9 -6
- package/src/components/embeds/reddit-embed-client.tsx +7 -3
- package/src/components/embeds/twitter-embed-client.tsx +7 -3
- package/src/components/layout/page-heading.tsx +52 -11
- package/src/components/ui/filter-checkbox-item.tsx +21 -5
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/layout-hold.tsx +38 -10
- package/src/components/ui/markdown/base-components.tsx +5 -2
- package/src/components/ui/markdown/rich/embed-overrides.tsx +12 -9
- package/src/components/ui/markdown-editor.tsx +6 -0
- package/src/components/ui/markdown-link-command.tsx +252 -0
- package/src/components/unified-filter-logic.tsx +1 -1
- package/src/components/vendor-tag.tsx +73 -69
- package/src/utils/format.ts +2 -3
- package/src/utils/index.ts +8 -5
- package/src/utils/markdown-link.ts +148 -0
- package/src/utils/markdown-to-plain.ts +64 -22
- package/src/utils/vendor-classification.ts +113 -34
- package/dist/utils/format-text-stub.d.ts +0 -5
- package/dist/utils/format-text-stub.d.ts.map +0 -1
- package/dist/utils/format-text-stub.js +0 -20
- package/src/utils/format-text-stub.ts +0 -17
|
@@ -22,15 +22,18 @@ import { Video } from '../../../features/video';
|
|
|
22
22
|
import { buildStandardLeafRenderers, hasRenderableSrc, type MdRenderProps } from '../base-components';
|
|
23
23
|
import type { TextSizeElement } from '../text-size';
|
|
24
24
|
|
|
25
|
-
/** Depth-first search of a hast node for the first `<a href>` matching `hostRe
|
|
26
|
-
function
|
|
25
|
+
/** Depth-first search of a hast node for the first `<a href>` matching `hostRe`, with the `rel` written on it. */
|
|
26
|
+
function findFirstLink(node: ElementContent | undefined, hostRe: RegExp): { href: string; rel?: string } | null {
|
|
27
27
|
if (!node || node.type !== 'element') return null;
|
|
28
28
|
if (node.tagName === 'a') {
|
|
29
|
-
const href = node.properties
|
|
30
|
-
if (typeof href === 'string' && hostRe.test(href))
|
|
29
|
+
const { href, rel } = node.properties;
|
|
30
|
+
if (typeof href === 'string' && hostRe.test(href)) {
|
|
31
|
+
const relText = Array.isArray(rel) ? rel.join(' ') : typeof rel === 'string' ? rel : undefined;
|
|
32
|
+
return { href, rel: relText };
|
|
33
|
+
}
|
|
31
34
|
}
|
|
32
35
|
for (const child of node.children) {
|
|
33
|
-
const found =
|
|
36
|
+
const found = findFirstLink(child, hostRe);
|
|
34
37
|
if (found) return found;
|
|
35
38
|
}
|
|
36
39
|
return null;
|
|
@@ -202,12 +205,12 @@ export function buildRichEmbedOverrides({
|
|
|
202
205
|
const { node, className } = bqProps;
|
|
203
206
|
const classNames: string = Array.isArray(className) ? className.join(' ') : (className ?? '');
|
|
204
207
|
if (classNames.includes('reddit-embed-bq')) {
|
|
205
|
-
const
|
|
206
|
-
if (
|
|
208
|
+
const post = findFirstLink(node, /reddit\.com/);
|
|
209
|
+
if (post) return <RedditEmbedClient url={post.href} rel={post.rel} />;
|
|
207
210
|
}
|
|
208
211
|
if (classNames.includes('twitter-tweet')) {
|
|
209
|
-
const
|
|
210
|
-
if (
|
|
212
|
+
const tweet = findFirstLink(node, /(?:twitter\.com|x\.com)\/[^/]+\/status\//);
|
|
213
|
+
if (tweet) return <TwitterEmbedClient url={tweet.href} rel={tweet.rel} />;
|
|
211
214
|
}
|
|
212
215
|
return standard.blockquote(bqProps);
|
|
213
216
|
},
|
|
@@ -7,6 +7,7 @@ import { useRef, useCallback, useState, useEffect } from 'react';
|
|
|
7
7
|
import dynamic from '../../embed-shims/next-dynamic';
|
|
8
8
|
import { cn } from '../../utils/cn';
|
|
9
9
|
import { IMAGE_FILE_ACCEPT, fileMediaType } from '../../utils/media-type';
|
|
10
|
+
import { useMarkdownLinkCommand } from './markdown-link-command';
|
|
10
11
|
|
|
11
12
|
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false });
|
|
12
13
|
|
|
@@ -135,6 +136,8 @@ export function MarkdownEditor({
|
|
|
135
136
|
const [isUploading, setIsUploading] = useState(false);
|
|
136
137
|
const [uploadProgress, setUploadProgress] = useState('');
|
|
137
138
|
const [defaultExtraCommands, setDefaultExtraCommands] = useState<ICommand[]>([]);
|
|
139
|
+
// The toolbar's link button opens the shared link dialog (text, URL, Follow / Nofollow).
|
|
140
|
+
const { commands: toolbarCommands, linkDialog } = useMarkdownLinkCommand();
|
|
138
141
|
|
|
139
142
|
useEffect(() => {
|
|
140
143
|
let cancelled = false;
|
|
@@ -352,6 +355,7 @@ export function MarkdownEditor({
|
|
|
352
355
|
visibleDragbar={true}
|
|
353
356
|
height={height}
|
|
354
357
|
minHeight={minHeight}
|
|
358
|
+
commands={toolbarCommands}
|
|
355
359
|
extraCommands={extraCommands}
|
|
356
360
|
style={
|
|
357
361
|
{
|
|
@@ -387,6 +391,8 @@ export function MarkdownEditor({
|
|
|
387
391
|
|
|
388
392
|
{uploadProgress && <p className="mt-1 text-ods-text-secondary text-h6">{uploadProgress}</p>}
|
|
389
393
|
|
|
394
|
+
{linkDialog}
|
|
395
|
+
|
|
390
396
|
<MarkdownEditorStyles />
|
|
391
397
|
</div>
|
|
392
398
|
);
|
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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 ||
|
|
43
|
-
textColor: undefined,
|
|
44
|
-
icon: <
|
|
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
|
-
|
|
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
|
|
234
|
+
return classificationTag(type, text);
|
|
231
235
|
default:
|
|
232
236
|
return {
|
|
233
237
|
text: text || type,
|
package/src/utils/format.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ProgramInstant } from './program-instant';
|
|
6
|
-
import {
|
|
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
|
-
|
|
945
|
-
return formatUnderscoreText(classification);
|
|
944
|
+
return vendorClassificationCopy(classification)?.label ?? formatUnderscoreText(classification);
|
|
946
945
|
}
|
|
947
946
|
|
|
948
947
|
/**
|
package/src/utils/index.ts
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
VENDOR_CLASSIFICATION_COPY,
|
|
25
|
+
vendorClassificationCopy,
|
|
26
|
+
vendorClassificationGroup,
|
|
27
|
+
compareVendorClassifications,
|
|
27
28
|
isOpenFrameClassification,
|
|
28
29
|
type VendorClassificationValue,
|
|
29
|
-
type
|
|
30
|
-
type
|
|
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';
|