@flamingo-stack/openframe-frontend-core 0.0.744 → 0.0.745
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-NURAL5HD.cjs → chunk-3LX5AGZO.cjs} +9 -9
- package/dist/{chunk-PYWHDPVC.cjs → chunk-6IQDQQ56.cjs} +25 -25
- package/dist/{chunk-FTYQS6VI.cjs → chunk-75BF7YJR.cjs} +7 -7
- package/dist/{chunk-H62FNZGG.cjs → chunk-7KHQHYXL.cjs} +11 -11
- package/dist/{chunk-65LJNDR2.cjs → chunk-7PQOZLVP.cjs} +9 -9
- package/dist/{chunk-7YDF3QBN.cjs → chunk-DJO7YMT3.cjs} +126 -13
- package/dist/{chunk-2JRCJV3M.cjs → chunk-FNIFUCXU.cjs} +3 -3
- package/dist/{chunk-35IQLGV3.cjs → chunk-GEEKJ7SH.cjs} +37 -37
- package/dist/{chunk-OC2LBQQJ.cjs → chunk-GOTEXQZN.cjs} +15 -15
- package/dist/{chunk-HHL23RFC.cjs → chunk-GQ2TRIOV.cjs} +33 -33
- package/dist/{chunk-2UBR53AN.cjs → chunk-HMSCUL4R.cjs} +4 -4
- package/dist/{chunk-HMSUYWSB.cjs → chunk-HVR7DPAY.cjs} +4 -4
- package/dist/{chunk-AT6ZI4CO.cjs → chunk-M4FKV2U5.cjs} +6 -6
- package/dist/{chunk-QZYF2YQ3.cjs → chunk-NFINQ7IZ.cjs} +4 -4
- package/dist/{chunk-HTQJYN47.cjs → chunk-PM5ELQBL.cjs} +17 -17
- package/dist/{chunk-HJ4KGA2M.cjs → chunk-QES5NV4T.cjs} +716 -532
- package/dist/{chunk-QSO56JZ2.cjs → chunk-RIGSZEXS.cjs} +66 -66
- package/dist/{cloud-tenants-JPXAXSPE.cjs → cloud-tenants-DECPWIYQ.cjs} +4 -4
- package/dist/components/case-studies/index.cjs +14 -14
- package/dist/components/chat/index.cjs +3 -3
- package/dist/components/contact/index.cjs +5 -5
- package/dist/components/docs/index.cjs +8 -8
- package/dist/components/embeds/index.cjs +5 -5
- 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 +6 -6
- package/dist/components/features/index.cjs +4 -4
- package/dist/components/help-center-pages/index.cjs +74 -74
- package/dist/components/index.cjs +41 -37
- package/dist/components/meeting-scheduler/index.cjs +22 -22
- package/dist/components/navigation/index.cjs +7 -7
- package/dist/components/onboarding-guides/index.cjs +6 -6
- package/dist/components/product-demo/index.cjs +7 -7
- package/dist/components/tickets/index.cjs +9 -9
- package/dist/components/ui/index.cjs +8 -4
- 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/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/{devices-ONC4JOVH.cjs → devices-HJKDRWQX.cjs} +5 -5
- package/dist/{embeddable-chat-7ZEGCDVF.cjs → embeddable-chat-3SKFBO2P.cjs} +64 -64
- package/dist/index.cjs +24 -8
- package/dist/{knowledge-G7XU5YST.cjs → knowledge-NH2CGY2C.cjs} +5 -5
- package/dist/{logs-5B7NY4G6.cjs → logs-KZYIDYQK.cjs} +12 -12
- package/dist/{policies-OYR3SLJN.cjs → policies-UAVTPIKM.cjs} +8 -8
- package/dist/{scripts-6CWHTY6U.cjs → scripts-FRPY43KU.cjs} +9 -9
- package/dist/{software-update-AJPIY4SQ.cjs → software-update-AEJJNLX5.cjs} +7 -7
- package/dist/utils/index.cjs +119 -4
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- 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/package.json +3 -1
- package/src/components/embeds/reddit-embed-client.tsx +7 -3
- package/src/components/embeds/twitter-embed-client.tsx +7 -3
- package/src/components/ui/index.ts +1 -0
- 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/utils/index.ts +1 -0
- package/src/utils/markdown-link.ts +148 -0
- package/src/utils/markdown-to-plain.ts +64 -22
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const AUTHORED_LINK_REL_TOKENS = ["nofollow", "ugc", "sponsored"];
|
|
2
|
+
const NEW_TAB_REL = "noopener noreferrer";
|
|
3
|
+
function authoredRelTokens(rel) {
|
|
4
|
+
if (!rel) return [];
|
|
5
|
+
const given = new Set(
|
|
6
|
+
(typeof rel === "string" ? rel.split(/\s+/) : rel).map((token) => token.trim().toLowerCase()).filter(Boolean)
|
|
7
|
+
);
|
|
8
|
+
return AUTHORED_LINK_REL_TOKENS.filter((token) => given.has(token));
|
|
9
|
+
}
|
|
10
|
+
function isNofollowRel(rel) {
|
|
11
|
+
return authoredRelTokens(rel).includes("nofollow");
|
|
12
|
+
}
|
|
13
|
+
function renderedLinkRel(authoredRel, opensInNewTab) {
|
|
14
|
+
const tokens = authoredRelTokens(authoredRel);
|
|
15
|
+
if (opensInNewTab) tokens.push(NEW_TAB_REL);
|
|
16
|
+
return tokens.length > 0 ? tokens.join(" ") : void 0;
|
|
17
|
+
}
|
|
18
|
+
const MARKDOWN_LINK_RE = /(?<!!)\[((?:\\.|[^\]\\])*)\]\(\s*<?([^\s)>]*)>?(?:\s+"[^"]*")?\s*\)/g;
|
|
19
|
+
const HTML_LINK_RE = /<a\s([^>]*)>([\s\S]*?)<\/a\s*>/gi;
|
|
20
|
+
function htmlAttribute(attributes, name) {
|
|
21
|
+
const match = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i").exec(attributes);
|
|
22
|
+
if (!match) return null;
|
|
23
|
+
return match[1] ?? match[2] ?? match[3] ?? "";
|
|
24
|
+
}
|
|
25
|
+
function decodeHtmlEntities(value) {
|
|
26
|
+
return value.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
27
|
+
}
|
|
28
|
+
function escapeHtmlAttribute(value) {
|
|
29
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
30
|
+
}
|
|
31
|
+
function escapeHtmlText(value) {
|
|
32
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
33
|
+
}
|
|
34
|
+
function escapeMarkdownLinkText(value) {
|
|
35
|
+
return value.replace(/([\\[\]<])/g, "\\$1");
|
|
36
|
+
}
|
|
37
|
+
function unescapeMarkdownLinkText(value) {
|
|
38
|
+
return value.replace(/\\([\\[\]<])/g, "$1");
|
|
39
|
+
}
|
|
40
|
+
function encodeMarkdownUrl(url) {
|
|
41
|
+
return url.replace(/\s/g, (match) => encodeURIComponent(match)).replace(/[()]/g, (match) => encodeURIComponent(match));
|
|
42
|
+
}
|
|
43
|
+
function findMarkdownLinkAt(text, selectionStart, selectionEnd = selectionStart) {
|
|
44
|
+
const from = Math.min(selectionStart, selectionEnd);
|
|
45
|
+
const to = Math.max(selectionStart, selectionEnd);
|
|
46
|
+
const contains = (start, end) => start <= from && to <= end;
|
|
47
|
+
for (const match of text.matchAll(HTML_LINK_RE)) {
|
|
48
|
+
const start = match.index ?? 0;
|
|
49
|
+
const end = start + match[0].length;
|
|
50
|
+
if (!contains(start, end)) continue;
|
|
51
|
+
const attributes = match[1] ?? "";
|
|
52
|
+
return {
|
|
53
|
+
start,
|
|
54
|
+
end,
|
|
55
|
+
text: decodeHtmlEntities(match[2] ?? ""),
|
|
56
|
+
url: decodeHtmlEntities(htmlAttribute(attributes, "href") ?? ""),
|
|
57
|
+
rel: authoredRelTokens(htmlAttribute(attributes, "rel"))
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
for (const match of text.matchAll(MARKDOWN_LINK_RE)) {
|
|
61
|
+
const start = match.index ?? 0;
|
|
62
|
+
const end = start + match[0].length;
|
|
63
|
+
if (!contains(start, end)) continue;
|
|
64
|
+
return { start, end, text: unescapeMarkdownLinkText(match[1] ?? ""), url: match[2] ?? "", rel: [] };
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
function formatMarkdownLink({ text, url, rel }) {
|
|
69
|
+
const label = text.length > 0 ? text : url;
|
|
70
|
+
const tokens = authoredRelTokens(rel);
|
|
71
|
+
if (tokens.length > 0) {
|
|
72
|
+
return `<a href="${escapeHtmlAttribute(url)}" rel="${tokens.join(" ")}">${escapeHtmlText(label)}</a>`;
|
|
73
|
+
}
|
|
74
|
+
return `[${escapeMarkdownLinkText(label)}](${encodeMarkdownUrl(url)})`;
|
|
75
|
+
}
|
|
76
|
+
export {
|
|
77
|
+
AUTHORED_LINK_REL_TOKENS,
|
|
78
|
+
authoredRelTokens,
|
|
79
|
+
findMarkdownLinkAt,
|
|
80
|
+
formatMarkdownLink,
|
|
81
|
+
isNofollowRel,
|
|
82
|
+
renderedLinkRel
|
|
83
|
+
};
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Convert markdown source to plain text. Used by both the SEO server component
|
|
3
|
-
* (article-body echo in JSON-LD) and chat entity-card preview (clamped previews
|
|
4
|
-
* inside chat cards).
|
|
5
|
-
*
|
|
6
|
-
* The two consumers differ only in pre/post processing — `mode: 'article'`
|
|
7
|
-
* strips code blocks + images + headings + list bullets and preserves paragraph
|
|
8
|
-
* breaks; `mode: 'preview'` collapses everything to a single line. Both share
|
|
9
|
-
* the same inline-marker strip (link / bold / italic / inline-code).
|
|
10
|
-
*
|
|
11
|
-
* All regex patterns are linear (negated character classes, non-greedy with
|
|
12
|
-
* bounded targets) — no nested quantifiers, no catastrophic backtracking on
|
|
13
|
-
* pathological input.
|
|
14
|
-
*/
|
|
15
1
|
export interface MarkdownToPlainOptions {
|
|
16
2
|
/**
|
|
17
3
|
* - `'article'` (default): preserve paragraph structure (`\n\n` as separator),
|
|
@@ -25,8 +11,9 @@ export interface MarkdownToPlainOptions {
|
|
|
25
11
|
/** Optional max-character cap. Truncates with no ellipsis (caller-decided). */
|
|
26
12
|
maxChars?: number;
|
|
27
13
|
}
|
|
28
|
-
/** Strip inline markdown markers (bold, italic, inline-code, links).
|
|
29
|
-
* by `extractSections`'s `stripFormattingMarkers` and
|
|
14
|
+
/** Strip inline markdown markers (bold, italic, inline-code, links) from ONE line of text.
|
|
15
|
+
* Shared by `extractSections`'s `stripFormattingMarkers` and the heading ids, whose output is
|
|
16
|
+
* an anchor: it stays a fixed, regex-only rule so no heading id ever moves. */
|
|
30
17
|
export declare function stripInlineMarkdown(s: string): string;
|
|
31
18
|
export declare function markdownToPlainText(markdown: string, options?: MarkdownToPlainOptions): string;
|
|
32
19
|
//# sourceMappingURL=markdown-to-plain.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"markdown-to-plain.d.ts","sourceRoot":"","sources":["../../src/utils/markdown-to-plain.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"markdown-to-plain.d.ts","sourceRoot":"","sources":["../../src/utils/markdown-to-plain.ts"],"names":[],"mappings":"AAwBA,MAAM,WAAW,sBAAsB;IACrC;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;IAC7B,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;gFAEgF;AAChF,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAMrD;AAyCD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,sBAA2B,GAAG,MAAM,CAmBlG"}
|
|
@@ -1,14 +1,44 @@
|
|
|
1
|
+
import { toText } from "hast-util-to-text";
|
|
2
|
+
import rehypeRaw from "rehype-raw";
|
|
3
|
+
import { remark } from "remark";
|
|
4
|
+
import remarkBreaks from "remark-breaks";
|
|
5
|
+
import remarkGfm from "remark-gfm";
|
|
6
|
+
import remarkRehype from "remark-rehype";
|
|
7
|
+
import { SKIP, visit } from "unist-util-visit";
|
|
1
8
|
function stripInlineMarkdown(s) {
|
|
2
9
|
return s.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/`([^`]+)`/g, "$1");
|
|
3
10
|
}
|
|
11
|
+
const toHtmlTree = remark().use(remarkGfm).use(remarkBreaks).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw);
|
|
12
|
+
const NOT_PROSE = /* @__PURE__ */ new Set(["pre", "img"]);
|
|
13
|
+
const TABLE_SECTIONS = /* @__PURE__ */ new Set(["thead", "tbody", "tfoot"]);
|
|
14
|
+
function proseTree(markdown) {
|
|
15
|
+
const tree = toHtmlTree.runSync(toHtmlTree.parse(markdown));
|
|
16
|
+
visit(tree, "element", (node, index, parent) => {
|
|
17
|
+
if (NOT_PROSE.has(node.tagName) && parent && index !== void 0) {
|
|
18
|
+
parent.children.splice(index, 1);
|
|
19
|
+
return [SKIP, index];
|
|
20
|
+
}
|
|
21
|
+
if (TABLE_SECTIONS.has(node.tagName) && parent && index !== void 0) {
|
|
22
|
+
const followedBySection = parent.children.slice(index + 1).some((sibling) => sibling.type === "element" && TABLE_SECTIONS.has(sibling.tagName));
|
|
23
|
+
const rows = node.children.filter(
|
|
24
|
+
(child) => child.type === "element" && child.tagName === "tr"
|
|
25
|
+
);
|
|
26
|
+
const lastRow = rows[rows.length - 1];
|
|
27
|
+
if (followedBySection && lastRow) {
|
|
28
|
+
lastRow.children.push({ type: "element", tagName: "br", properties: {}, children: [] });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return void 0;
|
|
32
|
+
});
|
|
33
|
+
return tree;
|
|
34
|
+
}
|
|
4
35
|
function markdownToPlainText(markdown, options = {}) {
|
|
5
36
|
const { mode = "article", maxChars } = options;
|
|
6
|
-
let out =
|
|
7
|
-
out = stripInlineMarkdown(out).replace(/^#+\s*/gm, "").replace(/^\s*[-*+]\s+/gm, "");
|
|
37
|
+
let out = toText(proseTree(markdown));
|
|
8
38
|
if (mode === "preview") {
|
|
9
39
|
out = out.replace(/\s+/g, " ").trim();
|
|
10
40
|
} else {
|
|
11
|
-
out = out.replace(/\n{3,}/g, "\n\n").trim();
|
|
41
|
+
out = out.replace(/[^\S\n]*\n[^\S\n]*/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
12
42
|
}
|
|
13
43
|
if (maxChars != null && out.length > maxChars) {
|
|
14
44
|
out = out.slice(0, maxChars);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flamingo-stack/openframe-frontend-core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.745",
|
|
4
4
|
"description": "Shared design system and components for all Flamingo platforms",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -480,6 +480,7 @@
|
|
|
480
480
|
"framer-motion": "^12.23.12",
|
|
481
481
|
"globals": "^16.4.0",
|
|
482
482
|
"graphql": "^16.12.0",
|
|
483
|
+
"hast-util-to-text": "^4.0.2",
|
|
483
484
|
"libphonenumber-js": "^1.12.40",
|
|
484
485
|
"lucide-react": "^0.454.0",
|
|
485
486
|
"mermaid": "^11.14.0",
|
|
@@ -504,6 +505,7 @@
|
|
|
504
505
|
"remark": "^15.0.1",
|
|
505
506
|
"remark-breaks": "^4.0.0",
|
|
506
507
|
"remark-gfm": "^4.0.1",
|
|
508
|
+
"remark-rehype": "^11.1.2",
|
|
507
509
|
"sonner": "^1.2.4",
|
|
508
510
|
"tailwind-merge": "^3.4.0",
|
|
509
511
|
"tailwindcss-animate": "^1.0.7",
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useState, useEffect, useRef } from 'react';
|
|
4
4
|
import Link from '../../embed-shims/next-link';
|
|
5
5
|
import { formatLargeNumber } from '../../utils/format';
|
|
6
|
+
import { renderedLinkRel } from '../../utils/markdown-link';
|
|
6
7
|
import type { MediaItem as CarouselMediaItem } from '../../utils/media-carousel-utils-stub';
|
|
7
8
|
import { socialCache } from '../../utils/social-embed-cache';
|
|
8
9
|
import { MediaCarousel } from '../media-carousel';
|
|
@@ -159,10 +160,13 @@ type RedditListingResponse = Array<{ data?: { children?: Array<{ data?: RedditPo
|
|
|
159
160
|
|
|
160
161
|
interface RedditEmbedProps {
|
|
161
162
|
url: string;
|
|
163
|
+
/** The `rel` the author wrote on the post's link (`nofollow` …), kept on the card's links to the post. */
|
|
164
|
+
rel?: string;
|
|
162
165
|
maxWidth?: number;
|
|
163
166
|
}
|
|
164
167
|
|
|
165
|
-
export function RedditEmbedClient({ url, maxWidth = 700 }: RedditEmbedProps) {
|
|
168
|
+
export function RedditEmbedClient({ url, maxWidth = 700, rel }: RedditEmbedProps) {
|
|
169
|
+
const postLinkRel = renderedLinkRel(rel, true);
|
|
166
170
|
const { redditProxyUrl } = useRichMarkdownRuntime();
|
|
167
171
|
const [redditData, setRedditData] = useState<RedditPost | null>(null);
|
|
168
172
|
const [loading, setLoading] = useState(true);
|
|
@@ -261,7 +265,7 @@ export function RedditEmbedClient({ url, maxWidth = 700 }: RedditEmbedProps) {
|
|
|
261
265
|
<Link
|
|
262
266
|
href={url}
|
|
263
267
|
target="_blank"
|
|
264
|
-
rel=
|
|
268
|
+
rel={postLinkRel}
|
|
265
269
|
className="inline-flex items-center space-x-2 rounded-md bg-[#FF4500] px-4 py-2 text-white transition-colors text-h6 hover:bg-[#E03D00]"
|
|
266
270
|
>
|
|
267
271
|
<RedditIcon />
|
|
@@ -562,7 +566,7 @@ export function RedditEmbedClient({ url, maxWidth = 700 }: RedditEmbedProps) {
|
|
|
562
566
|
<Link
|
|
563
567
|
href={url}
|
|
564
568
|
target="_blank"
|
|
565
|
-
rel=
|
|
569
|
+
rel={postLinkRel}
|
|
566
570
|
className="inline-flex items-center space-x-2 text-ods-accent transition-colors text-h6 hover:opacity-80"
|
|
567
571
|
>
|
|
568
572
|
<ExternalLinkIcon />
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useState, useEffect, useRef } from 'react';
|
|
4
4
|
import Image from '../../embed-shims/next-image';
|
|
5
5
|
import Link from '../../embed-shims/next-link';
|
|
6
|
+
import { renderedLinkRel } from '../../utils/markdown-link';
|
|
6
7
|
import { socialCache } from '../../utils/social-embed-cache';
|
|
7
8
|
import { XLogoIcon } from '../icons-v2-generated/brand-logos/x-logo-icon';
|
|
8
9
|
import { TwitterContainer } from './embed-container';
|
|
@@ -71,11 +72,14 @@ interface TwitterOEmbedResponse {
|
|
|
71
72
|
|
|
72
73
|
interface TwitterEmbedProps {
|
|
73
74
|
url: string;
|
|
75
|
+
/** The `rel` the author wrote on the post's link (`nofollow` …), kept on the card's links to the post. */
|
|
76
|
+
rel?: string;
|
|
74
77
|
tweetId?: string;
|
|
75
78
|
maxWidth?: number;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
export function TwitterEmbedClient({ url, tweetId, maxWidth = 700 }: TwitterEmbedProps) {
|
|
81
|
+
export function TwitterEmbedClient({ url, tweetId, maxWidth = 700, rel }: TwitterEmbedProps) {
|
|
82
|
+
const postLinkRel = renderedLinkRel(rel, true);
|
|
79
83
|
const { twitterProxyUrl } = useRichMarkdownRuntime();
|
|
80
84
|
const [tweetData, setTweetData] = useState<TwitterOEmbedResponse | null>(null);
|
|
81
85
|
const [fetchLoading, setLoading] = useState(true);
|
|
@@ -179,7 +183,7 @@ export function TwitterEmbedClient({ url, tweetId, maxWidth = 700 }: TwitterEmbe
|
|
|
179
183
|
<Link
|
|
180
184
|
href={url}
|
|
181
185
|
target="_blank"
|
|
182
|
-
rel=
|
|
186
|
+
rel={postLinkRel}
|
|
183
187
|
className="inline-flex items-center space-x-2 rounded-md bg-ods-bg-surface px-4 py-2 text-ods-text-primary transition-colors text-h6 hover:bg-ods-bg-active"
|
|
184
188
|
>
|
|
185
189
|
<XIcon />
|
|
@@ -318,7 +322,7 @@ export function TwitterEmbedClient({ url, tweetId, maxWidth = 700 }: TwitterEmbe
|
|
|
318
322
|
<Link
|
|
319
323
|
href={url}
|
|
320
324
|
target="_blank"
|
|
321
|
-
rel=
|
|
325
|
+
rel={postLinkRel}
|
|
322
326
|
className="inline-flex items-center space-x-2 text-ods-accent transition-colors text-h6 hover:opacity-80"
|
|
323
327
|
>
|
|
324
328
|
<ExternalLinkIcon />
|
|
@@ -14,6 +14,7 @@ import { useAuthedImageSrc } from '../../../hooks/use-authed-image-src';
|
|
|
14
14
|
import type { ResolveLinkResult } from '../../../types/doc-source';
|
|
15
15
|
import { cn } from '../../../utils/cn';
|
|
16
16
|
import { slugifyHeadingText } from '../../../utils/markdown-heading-id';
|
|
17
|
+
import { renderedLinkRel } from '../../../utils/markdown-link';
|
|
17
18
|
import { getHashTargetElement, navigateSamePageHash, HUB_HEADER_OFFSET_PX } from '../../../utils/same-page-hash-nav';
|
|
18
19
|
import { extractText, resolveFallbackHeadingId, useAssignedHeadingIds, useHeadingId } from './heading-ids';
|
|
19
20
|
import { MermaidDiagram } from './mermaid-diagram';
|
|
@@ -250,7 +251,7 @@ export function buildBaseComponents({
|
|
|
250
251
|
),
|
|
251
252
|
|
|
252
253
|
// --- links ---
|
|
253
|
-
a: ({ href, children, className: linkClassName }: MdRenderProps<'a'>) => {
|
|
254
|
+
a: ({ href, children, className: linkClassName, rel: authoredRel }: MdRenderProps<'a'>) => {
|
|
254
255
|
const isBroken = href !== undefined && brokenLinks.includes(href);
|
|
255
256
|
const isInternalDocLink =
|
|
256
257
|
propCurrentPath !== undefined &&
|
|
@@ -339,7 +340,9 @@ export function buildBaseComponents({
|
|
|
339
340
|
href={href}
|
|
340
341
|
className={`relative text-ods-accent no-underline transition-colors duration-200 after:absolute after:-bottom-0.5 after:left-0 after:h-0.5 after:w-0 after:bg-ods-accent after:transition-all after:duration-300 after:content-[''] hover:after:w-full ${linkClassName || ''}`}
|
|
341
342
|
target={href?.startsWith('http') ? '_blank' : undefined}
|
|
342
|
-
rel=
|
|
343
|
+
// The author's SEO tokens (`<a rel="nofollow">`, the editor's Nofollow choice) are kept;
|
|
344
|
+
// every other authored token is dropped, and a new-tab link always gets noopener noreferrer.
|
|
345
|
+
rel={renderedLinkRel(authoredRel, href?.startsWith('http') ?? false)}
|
|
343
346
|
onClick={inPageAnchorId !== null ? handleAnchorClick : undefined}
|
|
344
347
|
>
|
|
345
348
|
{children}
|
|
@@ -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
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -424,6 +424,7 @@ export * from './doc-path-utils';
|
|
|
424
424
|
export * from './doc-tree-nav';
|
|
425
425
|
export * from './tree-builder';
|
|
426
426
|
export * from './markdown-heading-id';
|
|
427
|
+
export * from './markdown-link';
|
|
427
428
|
export * from './markdown-section-extractor';
|
|
428
429
|
export * from './markdown-to-plain';
|
|
429
430
|
export * from './embed-url-converters';
|