@flamingo-stack/openframe-frontend-core 0.0.743 → 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.
Files changed (84) hide show
  1. package/dist/build/export-map.json +1 -1
  2. package/dist/{chunk-ZP5DFF3Q.cjs → chunk-3LX5AGZO.cjs} +9 -9
  3. package/dist/{chunk-MLZKPDPZ.cjs → chunk-6IQDQQ56.cjs} +25 -25
  4. package/dist/{chunk-NGUJVM6O.cjs → chunk-75BF7YJR.cjs} +7 -7
  5. package/dist/{chunk-6XCQXGQA.cjs → chunk-7KHQHYXL.cjs} +11 -11
  6. package/dist/{chunk-IRWJKVVZ.cjs → chunk-7PQOZLVP.cjs} +9 -9
  7. package/dist/{chunk-7YDF3QBN.cjs → chunk-DJO7YMT3.cjs} +126 -13
  8. package/dist/{chunk-YDFKFLDQ.cjs → chunk-FNIFUCXU.cjs} +3 -3
  9. package/dist/{chunk-YWMUZLU2.cjs → chunk-GEEKJ7SH.cjs} +37 -37
  10. package/dist/{chunk-BM6JZRVD.cjs → chunk-GOTEXQZN.cjs} +40 -16
  11. package/dist/{chunk-TKASH5YC.cjs → chunk-GQ2TRIOV.cjs} +33 -33
  12. package/dist/{chunk-UL6ZZSL4.cjs → chunk-HMSCUL4R.cjs} +4 -4
  13. package/dist/{chunk-MT7ELGCV.cjs → chunk-HVR7DPAY.cjs} +4 -4
  14. package/dist/{chunk-SQHTX5XP.cjs → chunk-M4FKV2U5.cjs} +6 -6
  15. package/dist/{chunk-Y2FN3E32.cjs → chunk-NFINQ7IZ.cjs} +4 -4
  16. package/dist/{chunk-XJO5ZHO6.cjs → chunk-PM5ELQBL.cjs} +17 -17
  17. package/dist/{chunk-WCY35GB5.cjs → chunk-QES5NV4T.cjs} +716 -532
  18. package/dist/{chunk-BNTELSHZ.cjs → chunk-RIGSZEXS.cjs} +66 -66
  19. package/dist/{cloud-tenants-DZC3ACG6.cjs → cloud-tenants-DECPWIYQ.cjs} +4 -4
  20. package/dist/components/case-studies/index.cjs +14 -14
  21. package/dist/components/chat/index.cjs +3 -3
  22. package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
  23. package/dist/components/chat/utils/history-merge.js +25 -1
  24. package/dist/components/contact/index.cjs +5 -5
  25. package/dist/components/docs/index.cjs +8 -8
  26. package/dist/components/embeds/index.cjs +5 -5
  27. package/dist/components/embeds/reddit-embed-client.d.ts +3 -1
  28. package/dist/components/embeds/reddit-embed-client.d.ts.map +1 -1
  29. package/dist/components/embeds/reddit-embed-client.js +5 -3
  30. package/dist/components/embeds/twitter-embed-client.d.ts +3 -1
  31. package/dist/components/embeds/twitter-embed-client.d.ts.map +1 -1
  32. package/dist/components/embeds/twitter-embed-client.js +5 -3
  33. package/dist/components/faq/index.cjs +6 -6
  34. package/dist/components/features/index.cjs +4 -4
  35. package/dist/components/help-center-pages/index.cjs +74 -74
  36. package/dist/components/index.cjs +41 -37
  37. package/dist/components/meeting-scheduler/index.cjs +22 -22
  38. package/dist/components/navigation/index.cjs +7 -7
  39. package/dist/components/onboarding-guides/index.cjs +6 -6
  40. package/dist/components/product-demo/index.cjs +7 -7
  41. package/dist/components/tickets/index.cjs +9 -9
  42. package/dist/components/ui/index.cjs +8 -4
  43. package/dist/components/ui/index.d.ts +1 -0
  44. package/dist/components/ui/index.d.ts.map +1 -1
  45. package/dist/components/ui/index.js +1 -0
  46. package/dist/components/ui/markdown/base-components.d.ts.map +1 -1
  47. package/dist/components/ui/markdown/base-components.js +3 -2
  48. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -1
  49. package/dist/components/ui/markdown/rich/embed-overrides.js +11 -8
  50. package/dist/components/ui/markdown-editor.d.ts.map +1 -1
  51. package/dist/components/ui/markdown-editor.js +4 -0
  52. package/dist/components/ui/markdown-link-command.d.ts +37 -0
  53. package/dist/components/ui/markdown-link-command.d.ts.map +1 -0
  54. package/dist/components/ui/markdown-link-command.js +180 -0
  55. package/dist/{devices-MFGE2JQV.cjs → devices-HJKDRWQX.cjs} +5 -5
  56. package/dist/{embeddable-chat-7ZEGCDVF.cjs → embeddable-chat-3SKFBO2P.cjs} +64 -64
  57. package/dist/index.cjs +24 -8
  58. package/dist/{knowledge-7VPS2ZBU.cjs → knowledge-NH2CGY2C.cjs} +5 -5
  59. package/dist/{logs-M3QPAUH7.cjs → logs-KZYIDYQK.cjs} +12 -12
  60. package/dist/{policies-UAVE6X62.cjs → policies-UAVTPIKM.cjs} +8 -8
  61. package/dist/{scripts-EDQ3IQKW.cjs → scripts-FRPY43KU.cjs} +9 -9
  62. package/dist/{software-update-B2MZVNPK.cjs → software-update-AEJJNLX5.cjs} +7 -7
  63. package/dist/utils/index.cjs +119 -4
  64. package/dist/utils/index.d.ts +1 -0
  65. package/dist/utils/index.d.ts.map +1 -1
  66. package/dist/utils/index.js +1 -0
  67. package/dist/utils/markdown-link.d.ts +44 -0
  68. package/dist/utils/markdown-link.d.ts.map +1 -0
  69. package/dist/utils/markdown-link.js +83 -0
  70. package/dist/utils/markdown-to-plain.d.ts +3 -16
  71. package/dist/utils/markdown-to-plain.d.ts.map +1 -1
  72. package/dist/utils/markdown-to-plain.js +33 -3
  73. package/package.json +3 -1
  74. package/src/components/chat/utils/history-merge.ts +46 -1
  75. package/src/components/embeds/reddit-embed-client.tsx +7 -3
  76. package/src/components/embeds/twitter-embed-client.tsx +7 -3
  77. package/src/components/ui/index.ts +1 -0
  78. package/src/components/ui/markdown/base-components.tsx +5 -2
  79. package/src/components/ui/markdown/rich/embed-overrides.tsx +12 -9
  80. package/src/components/ui/markdown-editor.tsx +6 -0
  81. package/src/components/ui/markdown-link-command.tsx +252 -0
  82. package/src/utils/index.ts +1 -0
  83. package/src/utils/markdown-link.ts +148 -0
  84. 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(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
27
+ }
28
+ function escapeHtmlAttribute(value) {
29
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
30
+ }
31
+ function escapeHtmlText(value) {
32
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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). Shared
29
- * by `extractSections`'s `stripFormattingMarkers` and `markdownToPlainText`. */
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":"AAAA;;;;;;;;;;;;;GAaG;AAEH,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;iFACiF;AACjF,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAMrD;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,sBAA2B,GAAG,MAAM,CA0BlG"}
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 = markdown.replace(/```[\s\S]*?```/g, "").replace(/!\[[^\]]*\]\([^)]+\)/g, "");
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.743",
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",
@@ -160,6 +160,45 @@ function sharesRequestKey(keys: ReadonlySet<string>, others: ReadonlySet<string>
160
160
  return false;
161
161
  }
162
162
 
163
+ /** Stream seqs of the ticket lifecycle receipts a set of rows renders. A
164
+ * `TICKET_EVENT` is persisted as its own row, so history stamps the row's seq
165
+ * onto the segment and it is the receipt's exact identity. */
166
+ function ticketEventSeqs(messages: readonly MergeableChatMessage[]): Set<number> {
167
+ const seqs = new Set<number>();
168
+ for (const m of messages) {
169
+ if (!Array.isArray(m.content)) continue;
170
+ for (const seg of m.content) {
171
+ if (seg.type === 'ticket_event' && typeof seg.streamSeq === 'number') seqs.add(seg.streamSeq);
172
+ }
173
+ }
174
+ return seqs;
175
+ }
176
+
177
+ /**
178
+ * A kept realtime row without the ticket receipts the window already renders.
179
+ *
180
+ * Receipts arrive standalone, so a live bubble made of them grows with every
181
+ * status change while its `streamSeq` follows the newest one - and coverage
182
+ * keeps or drops a row WHOLE. Once history persisted the older receipts but
183
+ * not the newest, the bubble was kept and its older cards rendered a second
184
+ * time beside their persisted twins (resolve → reopen in a dialog Fae never
185
+ * answered this session). Returns `null` for a row left with nothing to show;
186
+ * the streaming row is never removed, it is the bubble chunks are landing in.
187
+ */
188
+ function withoutPersistedTicketEvents<M extends MergeableChatMessage>(
189
+ m: M,
190
+ persistedSeqs: ReadonlySet<number>,
191
+ streamingMessageId: string | null,
192
+ ): M | null {
193
+ if (persistedSeqs.size === 0 || !Array.isArray(m.content)) return m;
194
+ const content = m.content.filter(
195
+ seg => !(seg.type === 'ticket_event' && typeof seg.streamSeq === 'number' && persistedSeqs.has(seg.streamSeq)),
196
+ );
197
+ if (content.length === m.content.length) return m;
198
+ if (content.length === 0 && m.id !== streamingMessageId) return null;
199
+ return { ...m, content };
200
+ }
201
+
163
202
  /** A user row's identity is its text under its author kind; empty text is no
164
203
  * identity (system rows persist with `content: ''`). `''` for any other row.
165
204
  * The kind is part of the key because technician (`authorType: 'admin'`) and
@@ -665,7 +704,13 @@ export function mergeHistoryWithRealtime<M extends MergeableChatMessage>(input:
665
704
  if (keepRealtime(m)) realtimeMessages.push(m);
666
705
  }
667
706
 
668
- return [...olderThanWindowMessages, ...processedToUse, ...realtimeMessages];
707
+ const persistedTicketEventSeqs = ticketEventSeqs(processedToUse);
708
+ const dedupedRealtimeMessages = realtimeMessages.flatMap(m => {
709
+ const kept = withoutPersistedTicketEvents(m, persistedTicketEventSeqs, streamingMessageId);
710
+ return kept ? [kept] : [];
711
+ });
712
+
713
+ return [...olderThanWindowMessages, ...processedToUse, ...dedupedRealtimeMessages];
669
714
  }
670
715
 
671
716
  export interface HistoryPrependResult<M extends MergeableChatMessage> {
@@ -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="noopener noreferrer"
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="noopener noreferrer"
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="noopener noreferrer"
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="noopener noreferrer"
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 />
@@ -204,6 +204,7 @@ export * from './tag-key-value-filter';
204
204
 
205
205
  // Markdown Editor (MDEditor-based)
206
206
  export * from './markdown-editor';
207
+ export * from './markdown-link-command';
207
208
 
208
209
  // File Upload
209
210
  export * from './file-upload';
@@ -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={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
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 findFirstHref(node: ElementContent | undefined, hostRe: RegExp): string | null {
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.href;
30
- if (typeof href === 'string' && hostRe.test(href)) return 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 = findFirstHref(child, hostRe);
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 postUrl = findFirstHref(node, /reddit\.com/);
206
- if (postUrl) return <RedditEmbedClient url={postUrl} />;
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 tweetUrl = findFirstHref(node, /(?:twitter\.com|x\.com)\/[^/]+\/status\//);
210
- if (tweetUrl) return <TwitterEmbedClient url={tweetUrl} />;
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
  );