@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.
Files changed (81) hide show
  1. package/dist/build/export-map.json +1 -1
  2. package/dist/{chunk-NURAL5HD.cjs → chunk-3LX5AGZO.cjs} +9 -9
  3. package/dist/{chunk-PYWHDPVC.cjs → chunk-6IQDQQ56.cjs} +25 -25
  4. package/dist/{chunk-FTYQS6VI.cjs → chunk-75BF7YJR.cjs} +7 -7
  5. package/dist/{chunk-H62FNZGG.cjs → chunk-7KHQHYXL.cjs} +11 -11
  6. package/dist/{chunk-65LJNDR2.cjs → chunk-7PQOZLVP.cjs} +9 -9
  7. package/dist/{chunk-7YDF3QBN.cjs → chunk-DJO7YMT3.cjs} +126 -13
  8. package/dist/{chunk-2JRCJV3M.cjs → chunk-FNIFUCXU.cjs} +3 -3
  9. package/dist/{chunk-35IQLGV3.cjs → chunk-GEEKJ7SH.cjs} +37 -37
  10. package/dist/{chunk-OC2LBQQJ.cjs → chunk-GOTEXQZN.cjs} +15 -15
  11. package/dist/{chunk-HHL23RFC.cjs → chunk-GQ2TRIOV.cjs} +33 -33
  12. package/dist/{chunk-2UBR53AN.cjs → chunk-HMSCUL4R.cjs} +4 -4
  13. package/dist/{chunk-HMSUYWSB.cjs → chunk-HVR7DPAY.cjs} +4 -4
  14. package/dist/{chunk-AT6ZI4CO.cjs → chunk-M4FKV2U5.cjs} +6 -6
  15. package/dist/{chunk-QZYF2YQ3.cjs → chunk-NFINQ7IZ.cjs} +4 -4
  16. package/dist/{chunk-HTQJYN47.cjs → chunk-PM5ELQBL.cjs} +17 -17
  17. package/dist/{chunk-HJ4KGA2M.cjs → chunk-QES5NV4T.cjs} +716 -532
  18. package/dist/{chunk-QSO56JZ2.cjs → chunk-RIGSZEXS.cjs} +66 -66
  19. package/dist/{cloud-tenants-JPXAXSPE.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/contact/index.cjs +5 -5
  23. package/dist/components/docs/index.cjs +8 -8
  24. package/dist/components/embeds/index.cjs +5 -5
  25. package/dist/components/embeds/reddit-embed-client.d.ts +3 -1
  26. package/dist/components/embeds/reddit-embed-client.d.ts.map +1 -1
  27. package/dist/components/embeds/reddit-embed-client.js +5 -3
  28. package/dist/components/embeds/twitter-embed-client.d.ts +3 -1
  29. package/dist/components/embeds/twitter-embed-client.d.ts.map +1 -1
  30. package/dist/components/embeds/twitter-embed-client.js +5 -3
  31. package/dist/components/faq/index.cjs +6 -6
  32. package/dist/components/features/index.cjs +4 -4
  33. package/dist/components/help-center-pages/index.cjs +74 -74
  34. package/dist/components/index.cjs +41 -37
  35. package/dist/components/meeting-scheduler/index.cjs +22 -22
  36. package/dist/components/navigation/index.cjs +7 -7
  37. package/dist/components/onboarding-guides/index.cjs +6 -6
  38. package/dist/components/product-demo/index.cjs +7 -7
  39. package/dist/components/tickets/index.cjs +9 -9
  40. package/dist/components/ui/index.cjs +8 -4
  41. package/dist/components/ui/index.d.ts +1 -0
  42. package/dist/components/ui/index.d.ts.map +1 -1
  43. package/dist/components/ui/index.js +1 -0
  44. package/dist/components/ui/markdown/base-components.d.ts.map +1 -1
  45. package/dist/components/ui/markdown/base-components.js +3 -2
  46. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -1
  47. package/dist/components/ui/markdown/rich/embed-overrides.js +11 -8
  48. package/dist/components/ui/markdown-editor.d.ts.map +1 -1
  49. package/dist/components/ui/markdown-editor.js +4 -0
  50. package/dist/components/ui/markdown-link-command.d.ts +37 -0
  51. package/dist/components/ui/markdown-link-command.d.ts.map +1 -0
  52. package/dist/components/ui/markdown-link-command.js +180 -0
  53. package/dist/{devices-ONC4JOVH.cjs → devices-HJKDRWQX.cjs} +5 -5
  54. package/dist/{embeddable-chat-7ZEGCDVF.cjs → embeddable-chat-3SKFBO2P.cjs} +64 -64
  55. package/dist/index.cjs +24 -8
  56. package/dist/{knowledge-G7XU5YST.cjs → knowledge-NH2CGY2C.cjs} +5 -5
  57. package/dist/{logs-5B7NY4G6.cjs → logs-KZYIDYQK.cjs} +12 -12
  58. package/dist/{policies-OYR3SLJN.cjs → policies-UAVTPIKM.cjs} +8 -8
  59. package/dist/{scripts-6CWHTY6U.cjs → scripts-FRPY43KU.cjs} +9 -9
  60. package/dist/{software-update-AJPIY4SQ.cjs → software-update-AEJJNLX5.cjs} +7 -7
  61. package/dist/utils/index.cjs +119 -4
  62. package/dist/utils/index.d.ts +1 -0
  63. package/dist/utils/index.d.ts.map +1 -1
  64. package/dist/utils/index.js +1 -0
  65. package/dist/utils/markdown-link.d.ts +44 -0
  66. package/dist/utils/markdown-link.d.ts.map +1 -0
  67. package/dist/utils/markdown-link.js +83 -0
  68. package/dist/utils/markdown-to-plain.d.ts +3 -16
  69. package/dist/utils/markdown-to-plain.d.ts.map +1 -1
  70. package/dist/utils/markdown-to-plain.js +33 -3
  71. package/package.json +3 -1
  72. package/src/components/embeds/reddit-embed-client.tsx +7 -3
  73. package/src/components/embeds/twitter-embed-client.tsx +7 -3
  74. package/src/components/ui/index.ts +1 -0
  75. package/src/components/ui/markdown/base-components.tsx +5 -2
  76. package/src/components/ui/markdown/rich/embed-overrides.tsx +12 -9
  77. package/src/components/ui/markdown-editor.tsx +6 -0
  78. package/src/components/ui/markdown-link-command.tsx +252 -0
  79. package/src/utils/index.ts +1 -0
  80. package/src/utils/markdown-link.ts +148 -0
  81. package/src/utils/markdown-to-plain.ts +64 -22
@@ -0,0 +1,148 @@
1
+ /**
2
+ * One link format for the markdown editors and one `rel` rule for the renderers.
3
+ *
4
+ * A FOLLOWED link is written as markdown, `[text](url)`, exactly as the editors always wrote it.
5
+ * A link with an authored `rel` (nofollow, sponsored or ugc, the values Google reads) is written as
6
+ * an HTML anchor, `<a href="url" rel="sponsored">text</a>`: markdown has no attribute syntax, and
7
+ * an HTML anchor is what stored content already uses for links that carry attributes (the
8
+ * sanitizer keeps `rel` on `a`). Reading back accepts both forms, so a link can be switched either
9
+ * way. Nothing is rewritten unless an editor changes a link.
10
+ *
11
+ * Server-safe: no React, no DOM.
12
+ */
13
+
14
+ /** The `rel` tokens an author may set on a link. Search engines read these; nothing else is kept. */
15
+ export const AUTHORED_LINK_REL_TOKENS = ['nofollow', 'ugc', 'sponsored'] as const;
16
+
17
+ export type AuthoredLinkRel = (typeof AUTHORED_LINK_REL_TOKENS)[number];
18
+
19
+ /** What a renderer adds to every link that opens in a new tab. */
20
+ const NEW_TAB_REL = 'noopener noreferrer';
21
+
22
+ /** The authored SEO tokens in a `rel` value, in their canonical order. */
23
+ export function authoredRelTokens(rel: string | readonly string[] | null | undefined): AuthoredLinkRel[] {
24
+ if (!rel) return [];
25
+ const given = new Set(
26
+ (typeof rel === 'string' ? rel.split(/\s+/) : rel).map(token => token.trim().toLowerCase()).filter(Boolean),
27
+ );
28
+ return AUTHORED_LINK_REL_TOKENS.filter(token => given.has(token));
29
+ }
30
+
31
+ /** Whether an authored `rel` asks search engines not to follow the link. */
32
+ export function isNofollowRel(rel: string | readonly string[] | null | undefined): boolean {
33
+ return authoredRelTokens(rel).includes('nofollow');
34
+ }
35
+
36
+ /**
37
+ * The `rel` a renderer writes for a link: the author's SEO tokens, plus `noopener noreferrer` when
38
+ * the link opens in a new tab. `undefined` when there is nothing to write.
39
+ */
40
+ export function renderedLinkRel(
41
+ authoredRel: string | readonly string[] | null | undefined,
42
+ opensInNewTab: boolean,
43
+ ): string | undefined {
44
+ const tokens: string[] = authoredRelTokens(authoredRel);
45
+ if (opensInNewTab) tokens.push(NEW_TAB_REL);
46
+ return tokens.length > 0 ? tokens.join(' ') : undefined;
47
+ }
48
+
49
+ /** A link as the editor's dialog edits it. */
50
+ export interface MarkdownLinkFields {
51
+ text: string;
52
+ url: string;
53
+ /** The authored `rel` values. Empty = a followed link. */
54
+ rel: AuthoredLinkRel[];
55
+ }
56
+
57
+ /** A link found in the editor's text, with the range it occupies. */
58
+ export interface MarkdownLinkMatch extends MarkdownLinkFields {
59
+ start: number;
60
+ end: number;
61
+ }
62
+
63
+ // `[text](url)` or `[text](url "title")`, never an image (`![…]`). The text may hold escaped brackets.
64
+ const MARKDOWN_LINK_RE = /(?<!!)\[((?:\\.|[^\]\\])*)\]\(\s*<?([^\s)>]*)>?(?:\s+"[^"]*")?\s*\)/g;
65
+ // One HTML anchor, attributes and text. Nested anchors are not valid HTML, so the first `</a>` ends it.
66
+ const HTML_LINK_RE = /<a\s([^>]*)>([\s\S]*?)<\/a\s*>/gi;
67
+
68
+ function htmlAttribute(attributes: string, name: string): string | null {
69
+ const match = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i').exec(attributes);
70
+ if (!match) return null;
71
+ return match[1] ?? match[2] ?? match[3] ?? '';
72
+ }
73
+
74
+ function decodeHtmlEntities(value: string): string {
75
+ return value
76
+ .replace(/&quot;/g, '"')
77
+ .replace(/&#39;/g, "'")
78
+ .replace(/&lt;/g, '<')
79
+ .replace(/&gt;/g, '>')
80
+ .replace(/&amp;/g, '&');
81
+ }
82
+
83
+ function escapeHtmlAttribute(value: string): string {
84
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
85
+ }
86
+
87
+ function escapeHtmlText(value: string): string {
88
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
89
+ }
90
+
91
+ /** Brackets end the link text and `<` would open raw HTML (CommonMark), so all three are escaped. */
92
+ function escapeMarkdownLinkText(value: string): string {
93
+ return value.replace(/([\\[\]<])/g, '\\$1');
94
+ }
95
+
96
+ function unescapeMarkdownLinkText(value: string): string {
97
+ return value.replace(/\\([\\[\]<])/g, '$1');
98
+ }
99
+
100
+ /** A URL inside `(…)` breaks on whitespace and unbalanced parentheses; percent-encode them. */
101
+ function encodeMarkdownUrl(url: string): string {
102
+ return url.replace(/\s/g, match => encodeURIComponent(match)).replace(/[()]/g, match => encodeURIComponent(match));
103
+ }
104
+
105
+ /**
106
+ * The link the selection `[selectionStart, selectionEnd]` is in or on, if any: a markdown link or an
107
+ * HTML anchor that contains the whole selection (a caret counts when it touches the link).
108
+ */
109
+ export function findMarkdownLinkAt(
110
+ text: string,
111
+ selectionStart: number,
112
+ selectionEnd: number = selectionStart,
113
+ ): MarkdownLinkMatch | null {
114
+ const from = Math.min(selectionStart, selectionEnd);
115
+ const to = Math.max(selectionStart, selectionEnd);
116
+ const contains = (start: number, end: number) => start <= from && to <= end;
117
+
118
+ for (const match of text.matchAll(HTML_LINK_RE)) {
119
+ const start = match.index ?? 0;
120
+ const end = start + match[0].length;
121
+ if (!contains(start, end)) continue;
122
+ const attributes = match[1] ?? '';
123
+ return {
124
+ start,
125
+ end,
126
+ text: decodeHtmlEntities(match[2] ?? ''),
127
+ url: decodeHtmlEntities(htmlAttribute(attributes, 'href') ?? ''),
128
+ rel: authoredRelTokens(htmlAttribute(attributes, 'rel')),
129
+ };
130
+ }
131
+ for (const match of text.matchAll(MARKDOWN_LINK_RE)) {
132
+ const start = match.index ?? 0;
133
+ const end = start + match[0].length;
134
+ if (!contains(start, end)) continue;
135
+ return { start, end, text: unescapeMarkdownLinkText(match[1] ?? ''), url: match[2] ?? '', rel: [] };
136
+ }
137
+ return null;
138
+ }
139
+
140
+ /** The text that writes a link: markdown when followed, an HTML anchor when it carries a `rel`. */
141
+ export function formatMarkdownLink({ text, url, rel }: MarkdownLinkFields): string {
142
+ const label = text.length > 0 ? text : url;
143
+ const tokens = authoredRelTokens(rel);
144
+ if (tokens.length > 0) {
145
+ return `<a href="${escapeHtmlAttribute(url)}" rel="${tokens.join(' ')}">${escapeHtmlText(label)}</a>`;
146
+ }
147
+ return `[${escapeMarkdownLinkText(label)}](${encodeMarkdownUrl(url)})`;
148
+ }
@@ -3,15 +3,24 @@
3
3
  * (article-body echo in JSON-LD) and chat entity-card preview (clamped previews
4
4
  * inside chat cards).
5
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).
6
+ * The text is what a reader of the rendered page reads: the markdown is parsed with the SAME
7
+ * pipeline the renderer uses (remark with GFM and line breaks, then rehype with raw HTML, as in
8
+ * `components/ui/markdown/engine.tsx`), and the result is the `innerText` of that HTML
9
+ * (`hast-util-to-text`). So link, emphasis and heading syntax, raw HTML tags (`<a rel>`, `<div>`),
10
+ * escapes (`\<`) and entities (`&amp;`) all come out as their text; script and style content and
11
+ * comments do not. Code blocks and images are left out: their content is not prose.
10
12
  *
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.
13
+ * The two consumers differ only in whitespace — `mode: 'article'` keeps paragraph breaks;
14
+ * `mode: 'preview'` collapses everything to a single line.
14
15
  */
16
+ import type { Element, Root } from 'hast';
17
+ import { toText } from 'hast-util-to-text';
18
+ import rehypeRaw from 'rehype-raw';
19
+ import { remark } from 'remark';
20
+ import remarkBreaks from 'remark-breaks';
21
+ import remarkGfm from 'remark-gfm';
22
+ import remarkRehype from 'remark-rehype';
23
+ import { SKIP, visit } from 'unist-util-visit';
15
24
 
16
25
  export interface MarkdownToPlainOptions {
17
26
  /**
@@ -27,8 +36,9 @@ export interface MarkdownToPlainOptions {
27
36
  maxChars?: number;
28
37
  }
29
38
 
30
- /** Strip inline markdown markers (bold, italic, inline-code, links). Shared
31
- * by `extractSections`'s `stripFormattingMarkers` and `markdownToPlainText`. */
39
+ /** Strip inline markdown markers (bold, italic, inline-code, links) from ONE line of text.
40
+ * Shared by `extractSections`'s `stripFormattingMarkers` and the heading ids, whose output is
41
+ * an anchor: it stays a fixed, regex-only rule so no heading id ever moves. */
32
42
  export function stripInlineMarkdown(s: string): string {
33
43
  return s
34
44
  .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // [text](url) → text
@@ -37,26 +47,58 @@ export function stripInlineMarkdown(s: string): string {
37
47
  .replace(/`([^`]+)`/g, '$1'); // `inline code` → inline code
38
48
  }
39
49
 
50
+ // The renderer's own pipeline, up to the HTML tree. Built once; `runSync` has no async plugins.
51
+ const toHtmlTree = remark()
52
+ .use(remarkGfm)
53
+ .use(remarkBreaks)
54
+ .use(remarkRehype, { allowDangerousHtml: true })
55
+ .use(rehypeRaw);
56
+
57
+ /** Elements whose content is not prose: code blocks and images. */
58
+ const NOT_PROSE = new Set(['pre', 'img']);
59
+
60
+ const TABLE_SECTIONS = new Set(['thead', 'tbody', 'tfoot']);
61
+
62
+ function proseTree(markdown: string): Root {
63
+ const tree: Root = toHtmlTree.runSync(toHtmlTree.parse(markdown));
64
+ visit(tree, 'element', (node: Element, index, parent) => {
65
+ if (NOT_PROSE.has(node.tagName) && parent && index !== undefined) {
66
+ parent.children.splice(index, 1);
67
+ return [SKIP, index];
68
+ }
69
+ // `innerText` breaks between rows of the same `thead` / `tbody` only, so the last header row
70
+ // ran into the first body row ("What it buysEnthusiast"). A section followed by another ends
71
+ // its last row with a line break.
72
+ if (TABLE_SECTIONS.has(node.tagName) && parent && index !== undefined) {
73
+ const followedBySection = parent.children
74
+ .slice(index + 1)
75
+ .some(sibling => sibling.type === 'element' && TABLE_SECTIONS.has(sibling.tagName));
76
+ const rows = node.children.filter(
77
+ (child): child is Element => child.type === 'element' && child.tagName === 'tr',
78
+ );
79
+ const lastRow = rows[rows.length - 1];
80
+ if (followedBySection && lastRow) {
81
+ lastRow.children.push({ type: 'element', tagName: 'br', properties: {}, children: [] });
82
+ }
83
+ }
84
+ return undefined;
85
+ });
86
+ return tree;
87
+ }
88
+
40
89
  export function markdownToPlainText(markdown: string, options: MarkdownToPlainOptions = {}): string {
41
90
  const { mode = 'article', maxChars } = options;
42
91
 
43
- let out = markdown
44
- // Fenced code blocks — must run BEFORE inline strippers (otherwise the
45
- // backtick-grave strip below grazes them).
46
- .replace(/```[\s\S]*?```/g, '')
47
- // Images — `![alt](src)` → empty (we drop them entirely in plain text).
48
- .replace(/!\[[^\]]*\]\([^)]+\)/g, '');
49
-
50
- out = stripInlineMarkdown(out)
51
- // Heading markers (any depth).
52
- .replace(/^#+\s*/gm, '')
53
- // List bullets at start-of-line.
54
- .replace(/^\s*[-*+]\s+/gm, '');
92
+ let out = toText(proseTree(markdown));
55
93
 
56
94
  if (mode === 'preview') {
57
95
  out = out.replace(/\s+/g, ' ').trim();
58
96
  } else {
59
- out = out.replace(/\n{3,}/g, '\n\n').trim();
97
+ out = out
98
+ // No spaces around a line break (a soft break keeps the space that followed it).
99
+ .replace(/[^\S\n]*\n[^\S\n]*/g, '\n')
100
+ .replace(/\n{3,}/g, '\n\n')
101
+ .trim();
60
102
  }
61
103
 
62
104
  if (maxChars != null && out.length > maxChars) {