tiptapify 0.3.5 → 0.4.1

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 (137) hide show
  1. package/dist/content.css +28 -0
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +110770 -65623
  4. package/dist/tiptapify.umd.js +106 -89
  5. package/index.d.ts +37 -0
  6. package/package.json +3 -2
  7. package/src/components/ImageBubbleMenu.vue +5 -33
  8. package/src/components/MenuBubble.test.ts +120 -0
  9. package/src/components/MenuBubble.vue +124 -134
  10. package/src/components/MenuFloating.vue +48 -45
  11. package/src/components/Tiptapify.upload.test.ts +10 -0
  12. package/src/components/Tiptapify.vue +85 -45
  13. package/src/components/Toolbar/Index.vue +10 -0
  14. package/src/components/UI/BubbleRow.vue +41 -0
  15. package/src/components/UI/TiptapifyDialog.test.ts +37 -3
  16. package/src/components/UI/TiptapifyDialog.vue +15 -4
  17. package/src/components/editorExtensions.test.ts +9 -0
  18. package/src/components/editorExtensions.ts +21 -1
  19. package/src/composables/fakeUploadEndpoint.ts +65 -0
  20. package/src/composables/useTiptapifyConfig.ts +19 -0
  21. package/src/composables/useTiptapifyLinkPreview.test.ts +51 -0
  22. package/src/composables/useTiptapifyLinkPreview.ts +60 -0
  23. package/src/composables/useTiptapifyUpload.test.ts +96 -0
  24. package/src/composables/useTiptapifyUpload.ts +126 -0
  25. package/src/extensions/components/ai/Dialog.test.ts +335 -0
  26. package/src/extensions/components/ai/Dialog.vue +607 -310
  27. package/src/extensions/components/ai/backend.test.ts +1 -0
  28. package/src/extensions/components/ai/backend.ts +2 -1
  29. package/src/extensions/components/ai/context.test.ts +149 -0
  30. package/src/extensions/components/ai/context.ts +145 -0
  31. package/src/extensions/components/ai/diff.test.ts +65 -0
  32. package/src/extensions/components/ai/diff.ts +120 -0
  33. package/src/extensions/components/ai/errors.test.ts +45 -0
  34. package/src/extensions/components/ai/errors.ts +67 -0
  35. package/src/extensions/components/ai/result.test.ts +144 -0
  36. package/src/extensions/components/ai/result.ts +101 -0
  37. package/src/extensions/components/ai/thinking.test.ts +45 -0
  38. package/src/extensions/components/ai/thinking.ts +58 -0
  39. package/src/extensions/components/formatExtra/sub/Button.vue +1 -1
  40. package/src/extensions/components/formatExtra/sup/Button.vue +1 -1
  41. package/src/extensions/components/media/iframe/Button.vue +6 -7
  42. package/src/extensions/components/media/iframe/IframeDialog.test.ts +177 -0
  43. package/src/extensions/components/media/iframe/IframeDialog.vue +477 -105
  44. package/src/extensions/components/media/iframe/embed.test.ts +123 -0
  45. package/src/extensions/components/media/iframe/embed.ts +236 -0
  46. package/src/extensions/components/media/iframe/index.test.ts +123 -0
  47. package/src/extensions/components/media/iframe/index.ts +92 -10
  48. package/src/extensions/components/media/image/ImageDialog.vue +430 -132
  49. package/src/extensions/components/media/image/caption.test.ts +66 -1
  50. package/src/extensions/components/media/image/caption.ts +27 -1
  51. package/src/extensions/components/media/image/imageDialog.test.ts +210 -0
  52. package/src/extensions/components/media/image/imageDialog.ts +117 -0
  53. package/src/extensions/components/media/image/index.ts +2 -1
  54. package/src/extensions/components/media/link/Button.test.ts +96 -0
  55. package/src/extensions/components/media/link/Button.vue +6 -2
  56. package/src/extensions/components/media/link/LinkDialog.display.test.ts +363 -0
  57. package/src/extensions/components/media/link/LinkDialog.vue +687 -61
  58. package/src/extensions/components/media/link/index.ts +111 -6
  59. package/src/extensions/components/media/link/link.marks.test.ts +89 -0
  60. package/src/extensions/components/media/link/linkDialog.test.ts +296 -0
  61. package/src/extensions/components/media/link/linkDialog.ts +154 -0
  62. package/src/extensions/components/media/linkCard/PasteCardMenu.test.ts +133 -0
  63. package/src/extensions/components/media/linkCard/PasteCardMenu.vue +168 -0
  64. package/src/extensions/components/media/linkCard/index.test.ts +146 -0
  65. package/src/extensions/components/media/linkCard/index.ts +177 -0
  66. package/src/extensions/components/media/linkCard/linkCardDialog.test.ts +87 -0
  67. package/src/extensions/components/media/linkCard/linkCardDialog.ts +62 -0
  68. package/src/extensions/components/media/linkCard/linkPreview.test.ts +102 -0
  69. package/src/extensions/components/media/linkCard/linkPreview.ts +84 -0
  70. package/src/extensions/components/media/linkCard/makeLinkCard.test.ts +59 -0
  71. package/src/extensions/components/media/linkCard/makeLinkCard.ts +58 -0
  72. package/src/extensions/components/media/linkCard/pasteCard.test.ts +190 -0
  73. package/src/extensions/components/media/linkCard/pasteCard.ts +153 -0
  74. package/src/extensions/components/media/video/Button.vue +2 -1
  75. package/src/extensions/components/media/video/VideoDialog.test.ts +233 -0
  76. package/src/extensions/components/media/video/VideoDialog.vue +748 -59
  77. package/src/extensions/components/media/video/index.test.ts +154 -0
  78. package/src/extensions/components/media/video/index.ts +70 -5
  79. package/src/extensions/components/media/video/providers.test.ts +148 -0
  80. package/src/extensions/components/media/video/providers.ts +241 -0
  81. package/src/extensions/components/media/video/videoFile.test.ts +98 -0
  82. package/src/extensions/components/media/video/videoFile.ts +142 -0
  83. package/src/extensions/components/misc/fullscreen/Button.test.ts +55 -0
  84. package/src/extensions/components/misc/fullscreen/Button.vue +2 -2
  85. package/src/extensions/components/style/StyleColor.test.ts +255 -0
  86. package/src/extensions/components/style/StyleColor.vue +358 -208
  87. package/src/extensions/components/style/color/Button.vue +1 -3
  88. package/src/extensions/components/style/color/colors.test.ts +191 -0
  89. package/src/extensions/components/style/color/colors.ts +168 -0
  90. package/src/extensions/components/style/highlight/Button.vue +1 -3
  91. package/src/i18n/locales/ar.json +226 -23
  92. package/src/i18n/locales/bg.json +225 -22
  93. package/src/i18n/locales/ch.json +226 -23
  94. package/src/i18n/locales/cz.json +226 -23
  95. package/src/i18n/locales/de.json +226 -23
  96. package/src/i18n/locales/el.json +225 -22
  97. package/src/i18n/locales/en.json +225 -22
  98. package/src/i18n/locales/es.json +226 -23
  99. package/src/i18n/locales/et.json +225 -22
  100. package/src/i18n/locales/fi.json +226 -23
  101. package/src/i18n/locales/fr.json +226 -23
  102. package/src/i18n/locales/he.json +225 -22
  103. package/src/i18n/locales/hi.json +225 -22
  104. package/src/i18n/locales/hr.json +225 -22
  105. package/src/i18n/locales/hu.json +226 -23
  106. package/src/i18n/locales/id.json +225 -22
  107. package/src/i18n/locales/it.json +226 -23
  108. package/src/i18n/locales/ja.json +226 -23
  109. package/src/i18n/locales/ko.json +226 -23
  110. package/src/i18n/locales/la.json +226 -23
  111. package/src/i18n/locales/lt.json +226 -23
  112. package/src/i18n/locales/nl.json +226 -23
  113. package/src/i18n/locales/no.json +227 -24
  114. package/src/i18n/locales/pl.json +226 -23
  115. package/src/i18n/locales/pt-BR.json +225 -22
  116. package/src/i18n/locales/pt.json +226 -23
  117. package/src/i18n/locales/ro.json +225 -22
  118. package/src/i18n/locales/ru.json +227 -24
  119. package/src/i18n/locales/se.json +226 -23
  120. package/src/i18n/locales/sk.json +225 -22
  121. package/src/i18n/locales/sl.json +225 -22
  122. package/src/i18n/locales/sr.json +225 -22
  123. package/src/i18n/locales/th.json +226 -23
  124. package/src/i18n/locales/tr.json +226 -23
  125. package/src/i18n/locales/uk.json +227 -24
  126. package/src/i18n/locales/vi.json +226 -23
  127. package/src/index.ts +10 -0
  128. package/src/skins/registry.test.ts +1 -1
  129. package/src/skins/tokens.reachability.test.ts +1 -0
  130. package/src/skins/tokens.ts +3 -3
  131. package/src/types/editor.ts +40 -1
  132. package/src/utils/contentStyles.test.ts +116 -0
  133. package/src/utils/contentStyles.ts +175 -0
  134. package/src/utils/mediaSize.test.ts +22 -0
  135. package/src/utils/mediaSize.ts +27 -0
  136. package/src/utils/validateHref.test.ts +47 -2
  137. package/src/utils/validateHref.ts +47 -7
package/src/index.ts CHANGED
@@ -5,6 +5,8 @@ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
5
5
  import { TiptapifyFooterAlignment } from '@tiptapify/types/editor'
6
6
  import { createAiBackendProvider } from '@tiptapify/extensions/components/ai/backend'
7
7
  import { registerSkin, defineSkin } from '@tiptapify/skins/registry'
8
+ import { contentCss, inlineContentStyles } from '@tiptapify/utils/contentStyles'
9
+ import * as imageDialog from '@tiptapify/extensions/components/media/image/imageDialog'
8
10
 
9
11
  import { Editor as TipTapEditor, nodeViewProps, NodeViewWrapper, VueNodeViewRenderer } from '@tiptap/vue-3'
10
12
  import { Node, Mark, markInputRule, markPasteRule, mergeAttributes } from '@tiptap/core'
@@ -52,9 +54,13 @@ export {
52
54
  mergeAttributes,
53
55
  registerSkin,
54
56
  defineSkin,
57
+ contentCss,
58
+ inlineContentStyles,
59
+ imageDialog,
55
60
  }
56
61
 
57
62
  export type { TiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
63
+ export type { TiptapifyContentStyles } from '@tiptapify/utils/contentStyles'
58
64
  export type { TiptapifyFooterAlignment }
59
65
  export type { CommandProps, InputRuleMatch, PasteRuleMatch }
60
66
  export type {
@@ -65,6 +71,10 @@ export type {
65
71
  TiptapifySkinConfig,
66
72
  TiptapifySkinInput,
67
73
  ResolvedShape,
74
+ TiptapifyLinkPreviewData,
75
+ TiptapifyLinkPreviewOptions,
76
+ TiptapifyUploadKind,
77
+ TiptapifyUploadOptions,
68
78
  } from '@tiptapify/types/editor'
69
79
  export type {
70
80
  TiptapifyAiBackendProviderOptions,
@@ -88,7 +88,7 @@ describe('skin registry', () => {
88
88
  expect(skin.className).toBe('tiptapify-skin-inline')
89
89
  expect(skin.shape.bubble).toBe('flat')
90
90
  expect(skin.tokens['--tf-bubble-radius']).toBe('2px')
91
- expect(skin.tokens['--tf-bubble-padding']).toBe('4px')
91
+ expect(skin.tokens['--tf-bubble-border']).toBe('1px solid rgba(var(--v-border-color), var(--v-border-opacity))')
92
92
  })
93
93
 
94
94
  it('ignores unknown token keys in dev with a warning', () => {
@@ -20,6 +20,7 @@ const SOURCES = [
20
20
  '../components/MenuBubble.vue',
21
21
  '../components/ImageBubbleMenu.vue',
22
22
  '../components/UI/TiptapifyDialog.vue',
23
+ '../components/UI/BubbleRow.vue',
23
24
  './menu.css',
24
25
  '../extensions/components/list/styles/ListStyleButton.vue',
25
26
  ]
@@ -33,7 +33,7 @@ export const baseColors: TiptapifySkinColors = {
33
33
  buttonActiveHoverBg: 'rgb(var(--v-theme-primary))',
34
34
  bubbleBg: 'rgb(var(--v-theme-surface))',
35
35
  bubbleBorder: 'none',
36
- bubbleShadow: '4px 4px 20px var(--dark-gray)',
36
+ bubbleShadow: '0 4px 14px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.08)',
37
37
  menuBorder: 'none',
38
38
  menuShadow: '0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12)',
39
39
  menuItemActiveBg: 'rgba(0, 0, 0, 0)',
@@ -62,8 +62,8 @@ export const baseMetrics: TiptapifySkinMetrics = {
62
62
  dividerHeight: '0px',
63
63
  buttonRadius: '4px',
64
64
  bubbleRadius: '6px',
65
- bubblePadding: '0px',
66
- bubbleGap: '0px',
65
+ bubblePadding: '4px',
66
+ bubbleGap: '2px',
67
67
  menuListPadding: '8px 0px',
68
68
  menuRadius: '4px',
69
69
  menuItemRadius: '0px',
@@ -11,7 +11,7 @@ export type variantFieldTypes = 'outlined' | 'plain' | 'filled' | 'solo' | 'solo
11
11
 
12
12
  export type TiptapifyFooterAlignment = 'start' | 'center' | 'end'
13
13
 
14
- // The skin of the editor chrome: 'default' is the classic look, 'pastel' is the quiet 0.3.3 look
14
+ // The skin of the editor chrome: 'default' is the classic look, 'pastel' is the quiet look
15
15
  export type TiptapifySkin = 'default' | 'pastel'
16
16
 
17
17
  // Structural variants a token cannot express (component markup, vuetify prop bindings).
@@ -127,6 +127,9 @@ export type TiptapifyAiChatMessage = {
127
127
  export type TiptapifyAiEditorContext = {
128
128
  prompt: string,
129
129
  selectedText: string,
130
+ // The text of the selection's paragraph before and after it (empty without a selection)
131
+ textBefore: string,
132
+ textAfter: string,
130
133
  text: string,
131
134
  html: string,
132
135
  json: object,
@@ -220,3 +223,39 @@ export type TiptapifyAiResolvedOptions = TiptapifyAiOptions & {
220
223
  enabled: boolean,
221
224
  promptExamples: TiptapifyAiPromptExample[],
222
225
  }
226
+
227
+ export type TiptapifyUploadKind = 'image' | 'video'
228
+
229
+ // The upload endpoint: `POST {endpoint}` takes a file (multipart `file` and `kind`) and answers `{ url }`
230
+ export type TiptapifyUploadOptions = {
231
+ endpoint: string,
232
+ // Extra request headers, e.g. authorization
233
+ headers?: Record<string, string>,
234
+ }
235
+
236
+ export type TiptapifyUploadContext = {
237
+ kind: TiptapifyUploadKind,
238
+ // Upload progress from 0 to 1
239
+ onProgress: (fraction: number) => void,
240
+ // Aborted when the user cancels the upload or closes the dialog
241
+ signal: AbortSignal,
242
+ }
243
+
244
+ // Stores a picked file and gives its URL: the upload endpoint, else (an image only) a base64 data URL
245
+ export type TiptapifyUpload = (file: File, context: TiptapifyUploadContext) => Promise<{ url: string }>
246
+
247
+ // The backend endpoint of link cards: it reads the OpenGraph data of pages (a browser can't, CORS)
248
+ export type TiptapifyLinkPreviewOptions = {
249
+ // `GET {endpoint}` answers the handshake, `GET {endpoint}?url=<page>` the data of the page
250
+ endpoint: string,
251
+ // Extra request headers, e.g. authorization
252
+ headers?: Record<string, string>,
253
+ }
254
+
255
+ export type TiptapifyLinkPreviewData = {
256
+ url: string,
257
+ title?: string,
258
+ description?: string,
259
+ image?: string,
260
+ siteName?: string,
261
+ }
@@ -0,0 +1,116 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { Editor } from '@tiptap/core'
3
+ import { editorExtensions } from '@tiptapify/components/editorExtensions'
4
+ import { contentCss, contentHTML, inlineContentStyles } from '@tiptapify/utils/contentStyles'
5
+
6
+ let editor: Editor | null = null
7
+
8
+ function editorWith(content: string): Editor {
9
+ editor = new Editor({ extensions: editorExtensions('', false, []), content })
10
+
11
+ return editor
12
+ }
13
+
14
+ afterEach(() => {
15
+ editor?.destroy()
16
+ editor = null
17
+ })
18
+
19
+ function element(html: string, selector: string): HTMLElement {
20
+ const template = document.createElement('template')
21
+ template.innerHTML = html
22
+
23
+ return template.content.querySelector<HTMLElement>(selector)!
24
+ }
25
+
26
+ const card = (layout: string) => `<a class="tiptapify-link-card" data-link-card="${layout}" href="https://example.com">`
27
+ + '<img class="tiptapify-link-card__image" src="/i.png" alt=""><span class="tiptapify-link-card__body">'
28
+ + '<span class="tiptapify-link-card__site">Example</span><span class="tiptapify-link-card__title">Title</span>'
29
+ + '<span class="tiptapify-link-card__description">Text</span></span></a>'
30
+
31
+ describe('content styles', () => {
32
+ it('make a stylesheet for a site, weightless under :where(), and one for the editor', () => {
33
+ expect(contentCss()).toContain(':where(.tiptapify-content) .tiptapify-link-card { display: flex;')
34
+ expect(contentCss('.tiptap', { weightless: false })).toContain('.tiptap .tiptapify-link-card { display: flex;')
35
+ expect(contentCss('.tiptap', { weightless: false })).not.toContain(':where(')
36
+ })
37
+
38
+ it('write the look of a link card into its elements, per layout', () => {
39
+ const compact = inlineContentStyles(card('compact'))
40
+ expect(element(compact, 'a').style.display).toBe('flex')
41
+ expect(element(compact, 'a').style.flexWrap).toBe('wrap')
42
+ expect(element(compact, 'a').style.color).toBe('inherit')
43
+ expect(element(compact, 'img').style.objectFit).toBe('cover')
44
+ expect(element(compact, '.tiptapify-link-card__title').style.fontWeight).toBe('600')
45
+ expect(element(compact, '.tiptapify-link-card__description').getAttribute('style')).toContain('-webkit-line-clamp: 3;')
46
+
47
+ const large = inlineContentStyles(card('large'))
48
+ expect(element(large, 'a').style.flexDirection).toBe('column')
49
+ expect(element(large, 'a').style.maxWidth).toBe('520px')
50
+ expect(element(large, 'img').getAttribute('style')).toContain('aspect-ratio: 1.91 / 1;')
51
+ })
52
+
53
+ it('keep the styles an element has and change nothing when applied twice', () => {
54
+ const html = '<figure data-align="left"><img src="/a.png" style="float: left;"><figcaption>Caption</figcaption></figure>'
55
+ const once = inlineContentStyles(html)
56
+
57
+ expect(element(once, 'img').style.float).toBe('left')
58
+ expect(element(once, 'img').style.display).toBe('block')
59
+ expect(element(once, 'figure').style.float).toBe('left')
60
+ expect(element(once, 'figure').style.maxWidth).toBe('50%')
61
+ expect(element(once, 'figcaption').style.textAlign).toBe('start')
62
+ expect(inlineContentStyles(once)).toBe(once)
63
+ })
64
+
65
+ it('size videos and embeds, and lay out task lists', () => {
66
+ const html = '<video src="/a.mp4"></video><video src="/b.mp4" width="320"></video>'
67
+ + '<div data-youtube-video=""><iframe src="https://www.youtube-nocookie.com/embed/x"></iframe></div>'
68
+ + '<ul data-type="taskList"><li data-type="taskItem"><label><input type="checkbox"></label><div><p>Task</p></div></li></ul>'
69
+ + '<p>Text</p>'
70
+ const styled = inlineContentStyles(html)
71
+
72
+ expect(element(styled, 'video[src="/a.mp4"]').getAttribute('style')).toContain('aspect-ratio: 16 / 9;')
73
+ expect(element(styled, 'video[src="/b.mp4"]').getAttribute('style')).toBe('display: block; max-width: 100%;')
74
+ expect(element(styled, 'iframe').getAttribute('style')).toContain('aspect-ratio: 16 / 9;')
75
+ expect(element(styled, 'ul').style.listStyle).toBe('none')
76
+ expect(element(styled, 'li').style.display).toBe('flex')
77
+ expect(element(styled, 'li p').style.margin).toBe('0px')
78
+ expect(element(styled, 'ul ~ p').hasAttribute('style')).toBe(false)
79
+ })
80
+
81
+ it('leave the HTML as it is for a site with content.css', () => {
82
+ expect(contentHTML(card('compact'), 'classes')).toBe(card('compact'))
83
+ })
84
+ })
85
+
86
+ describe('content styles in the editor', () => {
87
+ const content = `<p>Text</p>${card('compact')}`
88
+ + '<figure data-align="right"><img src="/a.png" style="float: right;"><figcaption>Caption</figcaption></figure>'
89
+ + '<div data-youtube-video=""><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe></div>'
90
+ + '<video src="/a.mp4" controls=""></video>'
91
+ + '<ul data-type="taskList"><li data-type="taskItem" data-checked="true"><p>Done</p></li></ul>'
92
+
93
+ it('are ignored when HTML with them loads again: it saves the same content', () => {
94
+ const clean = editorWith(content).getHTML()
95
+ editor!.destroy()
96
+
97
+ const styled = inlineContentStyles(clean)
98
+ const reloaded = editorWith(styled).getHTML()
99
+
100
+ expect(reloaded).toBe(clean)
101
+ expect(inlineContentStyles(reloaded)).toBe(styled)
102
+ })
103
+
104
+ it('keep a floated image as a figure with its float', () => {
105
+ const html = editorWith(content).getHTML()
106
+
107
+ expect(html).toContain('<figure data-align="right"><img src="/a.png" style="float: right;">')
108
+ })
109
+
110
+ it('keep a responsive YouTube video without a size when it loads again', () => {
111
+ const html = editorWith(content).getHTML()
112
+
113
+ expect(element(html, 'div[data-youtube-video] iframe').hasAttribute('width')).toBe(false)
114
+ expect(editor!.state.doc.child(3).attrs).toMatchObject({ width: null, height: null })
115
+ })
116
+ })
@@ -0,0 +1,175 @@
1
+ /*
2
+ * The look of the content the editor saves (link cards, captions, videos, embeds, task lists), described once
3
+ * for the `contentStyles` prop: `inline` writes it into `style` attributes (inlineContentStyles), `classes`
4
+ * leaves it to `tiptapify/content.css` (contentCss) around content in a `.tiptapify-content` element.
5
+ * Colors are left to the site: muted text and borders use opacity, so they suit a light and a dark theme alike.
6
+ * The editor parses its elements by attributes and classes only, so these styles are ignored on load.
7
+ */
8
+
9
+ // No dependencies: the build imports this file to write dist/content.css
10
+
11
+ export type TiptapifyContentStyles = 'inline' | 'classes'
12
+
13
+ export type ContentRule = { selector: string, style: Record<string, string> }
14
+
15
+ export const CONTENT_CLASS = 'tiptapify-content'
16
+
17
+ const muted = '0.7'
18
+ const border = '1px solid rgba(128, 128, 128, 0.35)'
19
+ const responsive = { width: '100%', height: 'auto', 'aspect-ratio': '16 / 9' }
20
+
21
+ // In order: a later rule wins over an earlier one for the same property
22
+ export const contentRules: ContentRule[] = [
23
+ // Link cards: compact (the image beside the texts, under them when narrow), large (image on top), text
24
+ {
25
+ selector: '.tiptapify-link-card',
26
+ style: {
27
+ display: 'flex',
28
+ 'flex-wrap': 'wrap',
29
+ overflow: 'hidden',
30
+ margin: '1rem 0',
31
+ border,
32
+ 'border-radius': '10px',
33
+ color: 'inherit',
34
+ 'text-decoration': 'none',
35
+ },
36
+ },
37
+ {
38
+ selector: '.tiptapify-link-card__image',
39
+ // min-width 0: an image in a row would not shrink below its own width (an OpenGraph image is 1200px)
40
+ style: { flex: '1 1 180px', 'min-width': '0', 'max-width': '100%', 'min-height': '100px', margin: '0', 'object-fit': 'cover' },
41
+ },
42
+ {
43
+ selector: '.tiptapify-link-card__body',
44
+ style: { display: 'flex', flex: '999 1 260px', 'flex-direction': 'column', gap: '4px', 'min-width': '0', padding: '12px 16px' },
45
+ },
46
+ { selector: '.tiptapify-link-card__site', style: { 'font-size': '0.8125em', opacity: muted } },
47
+ { selector: '.tiptapify-link-card__title', style: { 'font-weight': '600', 'line-height': '1.35' } },
48
+ {
49
+ selector: '.tiptapify-link-card__description',
50
+ style: {
51
+ display: '-webkit-box',
52
+ overflow: 'hidden',
53
+ 'font-size': '0.875em',
54
+ 'line-height': '1.45',
55
+ opacity: muted,
56
+ '-webkit-box-orient': 'vertical',
57
+ '-webkit-line-clamp': '3',
58
+ },
59
+ },
60
+ {
61
+ selector: '.tiptapify-link-card[data-link-card="large"]',
62
+ style: { 'flex-direction': 'column', 'flex-wrap': 'nowrap', 'max-width': '520px' },
63
+ },
64
+ {
65
+ selector: '.tiptapify-link-card[data-link-card="large"] .tiptapify-link-card__image',
66
+ style: { flex: 'none', width: '100%', height: 'auto', 'min-height': '0', 'aspect-ratio': '1.91 / 1' },
67
+ },
68
+ { selector: '.tiptapify-link-card[data-link-card="large"] .tiptapify-link-card__body', style: { flex: 'none' } },
69
+
70
+ // Images with their caption under them: centered, or floated left or right with the text around them
71
+ {
72
+ selector: 'figure',
73
+ style: {
74
+ display: 'flex',
75
+ 'flex-direction': 'column',
76
+ gap: '4px',
77
+ width: 'fit-content',
78
+ 'max-width': '100%',
79
+ margin: '1rem auto',
80
+ },
81
+ },
82
+ { selector: 'figure:not([data-align])', style: { 'align-items': 'center' } },
83
+ { selector: 'figure img', style: { display: 'block', 'max-width': '100%', height: 'auto', margin: '0' } },
84
+ { selector: 'figure figcaption', style: { 'font-size': '0.875em', opacity: muted, 'text-align': 'center' } },
85
+ { selector: 'figure[data-align] figcaption', style: { 'text-align': 'start' } },
86
+ {
87
+ selector: 'figure[data-align="left"]',
88
+ style: { float: 'left', 'max-width': '50%', margin: '0.25rem 1.25rem 1rem 0' },
89
+ },
90
+ {
91
+ selector: 'figure[data-align="right"]',
92
+ style: { float: 'right', 'max-width': '50%', margin: '0.25rem 0 1rem 1.25rem' },
93
+ },
94
+
95
+ // Videos and embeds: never wider than the text; without a size they fill its width at 16:9
96
+ { selector: 'video', style: { display: 'block', 'max-width': '100%' } },
97
+ { selector: 'video:not([width])', style: responsive },
98
+ { selector: 'div[data-youtube-video] iframe', style: { display: 'block', 'max-width': '100%', border: '0' } },
99
+ { selector: 'div[data-youtube-video] iframe:not([width])', style: responsive },
100
+ { selector: '.iframe-wrapper iframe', style: { display: 'block', 'max-width': '100%' } },
101
+ { selector: '.iframe-wrapper iframe[src^="https://player.vimeo.com/"]:not([width])', style: responsive },
102
+
103
+ // Task lists: a checkbox beside each item instead of a bullet
104
+ { selector: 'ul[data-type="taskList"]', style: { 'list-style': 'none', 'margin-left': '0', padding: '0' } },
105
+ { selector: 'ul[data-type="taskList"] ul[data-type="taskList"]', style: { margin: '0' } },
106
+ { selector: 'ul[data-type="taskList"] > li', style: { display: 'flex', 'align-items': 'flex-start' } },
107
+ { selector: 'ul[data-type="taskList"] > li > label', style: { flex: '0 0 auto', 'margin-right': '0.5rem' } },
108
+ { selector: 'ul[data-type="taskList"] > li > div', style: { flex: '1 1 auto' } },
109
+ // The text of a task sits on the line of its checkbox: no default margin of the paragraph above it
110
+ { selector: 'ul[data-type="taskList"] > li > div > p', style: { margin: '0' } },
111
+ ]
112
+
113
+ function declarations(style: Record<string, string>): string {
114
+ return Object.entries(style).map(([property, value]) => `${property}: ${value};`).join(' ')
115
+ }
116
+
117
+ /**
118
+ * The rules as a stylesheet for the content inside `scope`. For a site the scope is wrapped in `:where()`, so
119
+ * any rule of the site wins over these without `!important`; the editor keeps the scope's own weight.
120
+ */
121
+ export function contentCss(scope = `.${CONTENT_CLASS}`, { weightless = true }: { weightless?: boolean } = {}): string {
122
+ const prefix = weightless ? `:where(${scope})` : scope
123
+
124
+ return contentRules.map(({ selector, style }) => `${prefix} ${selector} { ${declarations(style)} }`).join('\n') + '\n'
125
+ }
126
+
127
+ function parseStyle(style: string): Map<string, string> {
128
+ const properties = new Map<string, string>()
129
+ for (const declaration of style.split(';')) {
130
+ const colon = declaration.indexOf(':')
131
+ if (colon > 0) {
132
+ properties.set(declaration.slice(0, colon).trim().toLowerCase(), declaration.slice(colon + 1).trim())
133
+ }
134
+ }
135
+
136
+ return properties
137
+ }
138
+
139
+ /**
140
+ * The content HTML with the rules written into its `style` attributes. Other properties an element has (the
141
+ * float of an image) are kept and the rules' own are overwritten, so applying it twice changes nothing.
142
+ * Without a DOM (server rendering) the HTML is returned as it is.
143
+ */
144
+ export function inlineContentStyles(html: string): string {
145
+ if (typeof document === 'undefined' || !html) {
146
+ return html
147
+ }
148
+
149
+ // The content of a template is inert: no script runs and no image loads while the HTML is restyled
150
+ const template = document.createElement('template')
151
+ template.innerHTML = html
152
+ const styles = new Map<Element, Map<string, string>>()
153
+
154
+ for (const { selector, style } of contentRules) {
155
+ for (const element of template.content.querySelectorAll(selector)) {
156
+ const properties = styles.get(element) ?? parseStyle(element.getAttribute('style') ?? '')
157
+ for (const [property, value] of Object.entries(style)) {
158
+ properties.delete(property)
159
+ properties.set(property, value)
160
+ }
161
+ styles.set(element, properties)
162
+ }
163
+ }
164
+
165
+ for (const [element, properties] of styles) {
166
+ element.setAttribute('style', [...properties].map(([property, value]) => `${property}: ${value};`).join(' '))
167
+ }
168
+
169
+ return template.innerHTML
170
+ }
171
+
172
+ /** The HTML the editor gives out: with the styles written in, or as it is for a site with content.css */
173
+ export function contentHTML(html: string, mode: TiptapifyContentStyles): string {
174
+ return mode === 'inline' ? inlineContentStyles(html) : html
175
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { ratioOf, resizeSide, toSize } from '@tiptapify/utils/mediaSize'
3
+
4
+ describe('media size', () => {
5
+ it('reads a size as a whole positive number', () => {
6
+ expect(toSize('640')).toBe(640)
7
+ expect(toSize(359.6)).toBe(360)
8
+ expect(toSize('')).toBeNull()
9
+ expect(toSize(-5)).toBeNull()
10
+ })
11
+
12
+ it('keeps the other side in the ratio when one side changes', () => {
13
+ expect(resizeSide({ width: 640, height: 360 }, 'width', 1280, 16 / 9)).toEqual({ width: 1280, height: 720 })
14
+ expect(resizeSide({ width: 640, height: 360 }, 'height', 450, 16 / 9)).toEqual({ width: 800, height: 450 })
15
+ expect(resizeSide({ width: 640, height: 360 }, 'width', 800, null)).toEqual({ width: 800, height: 360 })
16
+ })
17
+
18
+ it('takes the ratio of the size, else of the fallback', () => {
19
+ expect(ratioOf({ width: 400, height: 300 }, 16 / 9)).toBe(4 / 3)
20
+ expect(ratioOf({ width: 400, height: null }, 16 / 9)).toBe(16 / 9)
21
+ })
22
+ })
@@ -0,0 +1,27 @@
1
+ export type MediaSize = { width: number | null, height: number | null }
2
+
3
+ // A positive whole number from a number field or an attribute, else null
4
+ export function toSize(value: unknown): number | null {
5
+ const number = typeof value === 'number' ? value : parseFloat(String(value ?? ''))
6
+
7
+ return Number.isFinite(number) && number > 0 ? Math.round(number) : null
8
+ }
9
+
10
+ /**
11
+ * The size after one side changed: with a ratio (width / height) the other side follows it.
12
+ */
13
+ export function resizeSide(size: MediaSize, side: keyof MediaSize, value: unknown, ratio: number | null): MediaSize {
14
+ const changed = toSize(value)
15
+ if (!ratio || !changed) {
16
+ return { ...size, [side]: changed }
17
+ }
18
+
19
+ return side === 'width'
20
+ ? { width: changed, height: toSize(changed / ratio) }
21
+ : { width: toSize(changed * ratio), height: changed }
22
+ }
23
+
24
+ // The ratio (width / height) of a size with both sides, else the fallback
25
+ export function ratioOf(size: MediaSize, fallback: number): number {
26
+ return size.width && size.height ? size.width / size.height : fallback
27
+ }
@@ -67,14 +67,43 @@ describe('isValidHref', () => {
67
67
  expect(isValidHref('https://.com')).toBe(false)
68
68
  })
69
69
 
70
- it('rejects unsupported protocols', () => {
71
- expect(isValidHref('ftp://example.com')).toBe(false)
70
+ it('accepts any protocol with a host: ftp, sftp, file shares', () => {
71
+ for (const href of ['ftp://files.example.com/a.zip', 'sftp://user@host/dir', 'smb://server/share', 'ftp://192.168.0.10/']) {
72
+ expect(isValidHref(href), href).toBe(true)
73
+ }
74
+ })
75
+
76
+ it('accepts an internal host without a dot: intranet and localhost', () => {
77
+ for (const href of ['http://intranet/wiki/page', 'http://localhost:8080/admin', 'https://jira/browse/LIGA-1']) {
78
+ expect(isValidHref(href), href).toBe(true)
79
+ }
80
+ })
81
+
82
+ it('accepts other protocols without slashes: sms, skype, magnet', () => {
83
+ for (const href of ['sms:+380441234567', 'skype:user.name?call', 'magnet:?xt=urn:btih:abc']) {
84
+ expect(isValidHref(href), href).toBe(true)
85
+ }
86
+ })
87
+
88
+ it('rejects a host and port typed without a protocol: it is not a protocol', () => {
89
+ expect(isValidHref('localhost:8080/admin')).toBe(false)
90
+ })
91
+
92
+ it('rejects a protocol with slashes but no host', () => {
93
+ expect(isValidHref('ftp://')).toBe(false)
94
+ expect(isValidHref('ftp:///path')).toBe(false)
72
95
  })
73
96
 
74
97
  it('rejects javascript pseudo-protocols', () => {
75
98
  expect(isValidHref('javascript:alert(1)')).toBe(false)
76
99
  })
77
100
 
101
+ it('rejects the protocols that run code or embed content', () => {
102
+ for (const href of ['JavaScript:alert(1)', 'vbscript:msgbox(1)', 'data:text/html;base64,PHNjcmlwdD4=']) {
103
+ expect(isValidHref(href), href).toBe(false)
104
+ }
105
+ })
106
+
78
107
  it('rejects URLs containing spaces', () => {
79
108
  expect(isValidHref('https://exa mple.com')).toBe(false)
80
109
  })
@@ -90,4 +119,20 @@ describe('isValidHref', () => {
90
119
  it('rejects plain text', () => {
91
120
  expect(isValidHref('not a url')).toBe(false)
92
121
  })
122
+
123
+ it('accepts an anchor and links relative to the site or the page', () => {
124
+ for (const href of ['#section-2', '/news/politics?page=2', './about', '../index.html', '?tab=comments']) {
125
+ expect(isValidHref(href), href).toBe(true)
126
+ }
127
+ })
128
+
129
+ it('rejects relative links with spaces and a bare anchor or slash', () => {
130
+ for (const href of ['#', '/', '/news/a b', '# heading']) {
131
+ expect(isValidHref(href), href).toBe(false)
132
+ }
133
+ })
134
+
135
+ it('rejects a protocol-relative link', () => {
136
+ expect(isValidHref('//evil.example/x')).toBe(false)
137
+ })
93
138
  })
@@ -5,14 +5,25 @@ const regexTel = 'tel:\\+?[0-9]+'
5
5
 
6
6
  const hrefRegex = new RegExp(`^(${regexMailto}|${regexTel})$`, 'i')
7
7
 
8
- const httpPrefixRegex = /^https?:\/\//i
9
- const hostnameRegex = /^[^.]+(\.[^.]+)+$/
8
+ // An anchor (#id) or a link relative to the site (/path) or the page (./, ../, ?query); not a
9
+ // protocol-relative //host, which points to another site
10
+ const relativeHrefRegex = /^(#|\/(?!\/)|\.{1,2}\/|\?)\S+$/
10
11
 
11
- function isValidHttpUrl(href: string): boolean {
12
- if (!httpPrefixRegex.test(href) || /\s/.test(href)) {
13
- return false
14
- }
12
+ const schemeRegex = /^([a-z][a-z0-9+.-]*):(.*)$/i
13
+
14
+ // Schemes that run code or embed content instead of pointing to it
15
+ const blockedSchemes = new Set(['javascript', 'vbscript', 'data'])
16
+
17
+ // Schemes of addresses on a host: without `//` (`http:example.com`) they are a typo, not another kind of link
18
+ const hostSchemes = new Set(['http', 'https', 'ftp', 'ftps', 'sftp'])
19
+
20
+ // Host labels without empty ones (`example..com`, `.com`); a single label is an internal host (intranet, localhost)
21
+ const hostnameRegex = /^[^.]+(\.[^.]+)*$/
15
22
 
23
+ // `localhost:8080/admin` typed without a protocol: a host and a port, not a scheme `localhost:`
24
+ const portRegex = /^\d+([/?#]|$)/
25
+
26
+ function isValidHostUrl(href: string): boolean {
16
27
  try {
17
28
  return hostnameRegex.test(new URL(href).hostname)
18
29
  } catch {
@@ -20,6 +31,35 @@ function isValidHttpUrl(href: string): boolean {
20
31
  }
21
32
  }
22
33
 
34
+ /**
35
+ * A link address: any protocol with a host (`https://`, `ftp://`, `smb://`, internal hosts without a dot
36
+ * included), other protocols (`mailto:`, `tel:`, `sms:`, `magnet:`…), anchors and relative links. Protocols
37
+ * that run code (`javascript:`, `vbscript:`, `data:`) are rejected.
38
+ */
23
39
  export function isValidHref(href: string): boolean {
24
- return isValidHttpUrl(href || '') || hrefRegex.test(href || '')
40
+ const value = href || ''
41
+ if (relativeHrefRegex.test(value)) {
42
+ return true
43
+ }
44
+
45
+ const match = schemeRegex.exec(value)
46
+ if (!match || /\s/.test(value)) {
47
+ return false
48
+ }
49
+
50
+ const scheme = match[1].toLowerCase()
51
+ const rest = match[2]
52
+ if (blockedSchemes.has(scheme)) {
53
+ return false
54
+ }
55
+
56
+ if (rest.startsWith('//')) {
57
+ return !rest.startsWith('///') && isValidHostUrl(value)
58
+ }
59
+
60
+ if (scheme === 'mailto' || scheme === 'tel') {
61
+ return hrefRegex.test(value)
62
+ }
63
+
64
+ return !hostSchemes.has(scheme) && rest !== '' && !portRegex.test(rest)
25
65
  }