@bagelink/vue 1.17.6 → 1.18.7

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bagelink/vue",
3
3
  "type": "module",
4
- "version": "1.17.6",
4
+ "version": "1.18.7",
5
5
  "description": "Bagel core sdk packages",
6
6
  "author": {
7
7
  "name": "Bagel Studio",
@@ -335,7 +335,7 @@ onMounted(() => {
335
335
  <template #trigger>
336
336
  <div ref="datePickerRef" class="date-picker-container">
337
337
  <TextInput
338
- v-model="inputValue" iconStart="calendar"
338
+ v-model="inputValue" iconStart="calendar_month"
339
339
  :placeholder="underlined ? ' ' : (resolveI18n(placeholder) || defaultPlaceholder)"
340
340
  :required="underlined ? false : required" :disabled="!isEditable"
341
341
  :error="!isValid ? $t('date.invalidFormat') : error"
@@ -30,6 +30,7 @@ import {
30
30
  import type { BagelInputShellProps } from '../bagelInputShell'
31
31
  import { useBagelInputShell } from '../bagelInputShell'
32
32
  import { RICH_TEXT_EXTENSION_REGISTRY_KEY } from './richTextExtensions'
33
+ import { normalizeRichTextLink, sanitizeRichTextLinks } from './utils/linkPolicy'
33
34
 
34
35
  // Disable automatic inheritance of non-prop attributes
35
36
  defineOptions({
@@ -696,7 +697,7 @@ function showTooltipMessage(message: string, x?: number, y?: number) {
696
697
  function openLinkModal(selection: Selection, range: Range, existingLink: HTMLAnchorElement | null) {
697
698
  pendingLinkData = { selection, range, existingLink }
698
699
  linkForm.value = {
699
- url: existingLink?.href || '',
700
+ url: existingLink?.getAttribute('href') || '',
700
701
  title: existingLink?.getAttribute('title') || '',
701
702
  openInNewTab: existingLink?.target !== '_self'
702
703
  }
@@ -705,17 +706,19 @@ function openLinkModal(selection: Selection, range: Range, existingLink: HTMLAnc
705
706
 
706
707
  // Function to submit link
707
708
  function submitLink() {
708
- if (!pendingLinkData || !linkForm.value.url) { return }
709
+ const normalizedUrl = normalizeRichTextLink(linkForm.value.url)
710
+ if (!pendingLinkData || !normalizedUrl) { return }
709
711
 
710
712
  const { selection, range, existingLink } = pendingLinkData
711
- const { url, title, openInNewTab } = linkForm.value
713
+ const { title, openInNewTab } = linkForm.value
712
714
  const { doc } = editor.state
713
715
 
714
716
  if (!doc) { return }
717
+ linkForm.value.url = normalizedUrl
715
718
 
716
719
  if (existingLink) {
717
720
  // Update existing link
718
- existingLink.href = url
721
+ existingLink.setAttribute('href', normalizedUrl)
719
722
  if (title.trim()) {
720
723
  existingLink.title = title.trim()
721
724
  } else {
@@ -731,7 +734,7 @@ function submitLink() {
731
734
  // Create new link
732
735
  try {
733
736
  const anchor = doc.createElement('a')
734
- anchor.href = url
737
+ anchor.setAttribute('href', normalizedUrl)
735
738
  if (title.trim()) {
736
739
  anchor.title = title.trim()
737
740
  }
@@ -766,14 +769,8 @@ function submitLink() {
766
769
 
767
770
  // Function to visit link
768
771
  function visitLink() {
769
- if (!linkForm.value.url || !isValidUrl(linkForm.value.url)) { return }
770
-
771
- let url = linkForm.value.url.trim()
772
-
773
- // Add https:// if no protocol is specified
774
- if (!url.match(/^https?:\/\//)) {
775
- url = `https://${url}`
776
- }
772
+ const url = normalizeRichTextLink(linkForm.value.url)
773
+ if (!url) { return }
777
774
 
778
775
  if (linkForm.value.openInNewTab) {
779
776
  window.open(url, '_blank', 'noopener,noreferrer')
@@ -784,20 +781,7 @@ function visitLink() {
784
781
 
785
782
  // Function to validate URL
786
783
  function isValidUrl(url: string): boolean {
787
- if (!url || url.trim() === '') { return false }
788
-
789
- try {
790
- URL.canParse(url)
791
- return true
792
- } catch {
793
- // Try with https:// prefix if it's missing
794
- try {
795
- URL.canParse(`https://${url}`)
796
- return true
797
- } catch {
798
- return false
799
- }
800
- }
784
+ return normalizeRichTextLink(url) !== null
801
785
  }
802
786
 
803
787
  // Function to open image modal
@@ -931,7 +915,7 @@ function submitImage() {
931
915
 
932
916
  // Wrap the image in an anchor when a link is provided, otherwise return the bare image
933
917
  function createImageContent(): HTMLElement {
934
- const link = imageForm.value.link?.trim()
918
+ const link = normalizeRichTextLink(imageForm.value.link)
935
919
  if (!link) { return img }
936
920
 
937
921
  const anchor = doc!.createElement('a')
@@ -2001,6 +1985,10 @@ function getCleanHTML(doc: Document | null): string {
2001
1985
  if (fig.classList.length === 0) { fig.removeAttribute('class') }
2002
1986
  })
2003
1987
 
1988
+ // Keep imported/pasted legacy links from leaking into saved output. Invalid
1989
+ // destinations lose their href, but their visible content is preserved.
1990
+ sanitizeRichTextLinks(clone)
1991
+
2004
1992
  const html = clone.innerHTML
2005
1993
  if (!clone.textContent?.trim() && !clone.querySelector('img, video, iframe, table, hr, figure')) {
2006
1994
  return ''
@@ -0,0 +1,48 @@
1
+ const EXPLICIT_SAFE_URL = /^(?:https?:\/\/|mailto:|tel:)/i
2
+ const BARE_DOMAIN = /^(?:localhost(?::\d+)?|(?:[a-z0-9-]+\.)+[a-z]{2,})(?:[/:?#][^\s]*)?$/i
3
+
4
+ /**
5
+ * Validate and normalize a link authored in RichText.
6
+ *
7
+ * Relative application paths must be root-relative (`/contact`). Ambiguous
8
+ * imported CMS links (`edit?...`, `admin/...`) are rejected instead of being
9
+ * silently resolved against the current page.
10
+ */
11
+ export function normalizeRichTextLink(value: unknown): string | null {
12
+ if (typeof value !== 'string') return null
13
+ const url = value.trim()
14
+ if (!url || /[\u0000-\u001F\u007F]/.test(url)) return null
15
+
16
+ if (url.startsWith('/') && !url.startsWith('//')) return url
17
+ if (url.startsWith('#') && url.length > 1) return url
18
+
19
+ if (EXPLICIT_SAFE_URL.test(url)) {
20
+ if (/^mailto:/i.test(url)) return url.slice(7).trim() ? url : null
21
+ if (/^tel:/i.test(url)) return url.slice(4).trim() ? url : null
22
+ try {
23
+ const parsed = new URL(url)
24
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? url : null
25
+ } catch {
26
+ return null
27
+ }
28
+ }
29
+
30
+ return BARE_DOMAIN.test(url) ? `https://${url}` : null
31
+ }
32
+
33
+ /** Remove invalid destinations while preserving their visible linked content. */
34
+ export function sanitizeRichTextLinks(root: ParentNode): number {
35
+ let removed = 0
36
+ for (const anchor of Array.from(root.querySelectorAll<HTMLAnchorElement>('a[href]'))) {
37
+ const normalized = normalizeRichTextLink(anchor.getAttribute('href'))
38
+ if (normalized) {
39
+ anchor.setAttribute('href', normalized)
40
+ continue
41
+ }
42
+ anchor.removeAttribute('href')
43
+ anchor.removeAttribute('target')
44
+ anchor.removeAttribute('rel')
45
+ removed++
46
+ }
47
+ return removed
48
+ }
@@ -2,7 +2,7 @@
2
2
  import type { IconType, Option } from '../../../index'
3
3
  import type { AlignedPlacement } from '../../Dropdown.types'
4
4
  import { Btn, Card, Skeleton, Dropdown, Icon, TextInput, useSearch, useI18n, resolveI18n } from '../../../index'
5
- import { computed, onMounted, ref, useId, watch } from 'vue'
5
+ import { computed, nextTick, onMounted, ref, useId, watch } from 'vue'
6
6
  import { getOptionIcon, getOptionLabel, getOptionSubtitle, getOptionValue } from '../../../utils/options'
7
7
  import 'floating-vue/style.css'
8
8
  import type { BagelInputShellProps } from './bagelInputShell'
@@ -232,8 +232,10 @@ watch(
232
232
  watch(() => open.value, (value) => {
233
233
  if (value) {
234
234
  highlightedIndex.value = -1
235
- // Scroll to selected item when dropdown opens
236
- setTimeout(() => { scrollToSelectedItem() }, 10)
235
+ nextTick(() => {
236
+ if (props.searchable) searchInput.value?.focus()
237
+ scrollToSelectedItem()
238
+ })
237
239
  }
238
240
  })
239
241
 
@@ -329,7 +329,7 @@ flex-shrink: 0;
329
329
  box-sizing: border-box;
330
330
  min-height: var(--bgl-app-header-h, 60px);
331
331
  gap: 0.5rem 0.75rem;
332
- padding-inline: calc(var(--bgl-content-padding, 1rem) + 0.05rem);
332
+ padding-inline: var(--bgl-content-padding, 1rem);
333
333
  padding-block: 0.5rem;
334
334
  flex-wrap: wrap;
335
335
  }
@@ -0,0 +1,37 @@
1
+ // @bun
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { mount } from '@vue/test-utils'
4
+ import { nextTick } from 'vue'
5
+ import { createI18n } from '../../i18n'
6
+ import AssetPreview from './AssetPreview.vue'
7
+
8
+ createI18n()
9
+
10
+ afterEach(() => {
11
+ vi.restoreAllMocks()
12
+ })
13
+
14
+ describe('AssetPreview protected delivery', () => {
15
+ it('uses the provided authenticated loader and revokes its object URL', async () => {
16
+ const createObjectURL = vi.fn(() => 'blob:protected')
17
+ const revokeObjectURL = vi.fn()
18
+ Object.defineProperties(URL, {
19
+ createObjectURL: { configurable: true, value: createObjectURL },
20
+ revokeObjectURL: { configurable: true, value: revokeObjectURL },
21
+ })
22
+ const load = vi.fn(async () => new Blob(['protected']))
23
+ const wrapper = mount(AssetPreview, {
24
+ props: { asset: { asset_id: 'asset-1' }, type: 'file', load },
25
+ global: { stubs: { BglBtn: { props: ['href'], template: '<a :href="href"><slot /></a>' } } },
26
+ })
27
+ await nextTick()
28
+ await nextTick()
29
+
30
+ expect(load).toHaveBeenCalledWith({ asset_id: 'asset-1' })
31
+ expect(createObjectURL).toHaveBeenCalledOnce()
32
+ expect(wrapper.get('a').attributes('href')).toBe('blob:protected')
33
+
34
+ wrapper.unmount()
35
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:protected')
36
+ })
37
+ })
@@ -1,13 +1,54 @@
1
1
  <script setup lang="ts">
2
2
  import { useAssetDelivery, type AssetReference } from '../../assets'
3
3
  import { Btn, Image } from '../../index'
4
- const props = defineProps<{ asset: AssetReference, type: string, name?: string }>()
5
- const { url, error, onError } = useAssetDelivery(() => props.asset)
4
+ import { computed, onUnmounted, ref, watch } from 'vue'
5
+
6
+ const props = defineProps<{
7
+ asset: AssetReference
8
+ type: string
9
+ name?: string
10
+ /** Authenticated byte loader for protected assets. Public assets omit it. */
11
+ load?: (asset: AssetReference) => Promise<Blob>
12
+ }>()
13
+ const publicDelivery = useAssetDelivery(() => props.load ? undefined : props.asset)
14
+ const protectedUrl = ref<string>()
15
+ const protectedError = ref(false)
16
+ let generation = 0
17
+
18
+ function clearProtectedUrl() {
19
+ if (protectedUrl.value) URL.revokeObjectURL(protectedUrl.value)
20
+ protectedUrl.value = undefined
21
+ }
22
+
23
+ watch(() => [props.asset.asset_id, props.load] as const, async () => {
24
+ const current = ++generation
25
+ clearProtectedUrl()
26
+ protectedError.value = false
27
+ if (!props.load) return
28
+ try {
29
+ const blob = await props.load(props.asset)
30
+ if (current === generation) protectedUrl.value = URL.createObjectURL(blob)
31
+ } catch {
32
+ if (current === generation) protectedError.value = true
33
+ }
34
+ }, { immediate: true })
35
+
36
+ onUnmounted(() => {
37
+ generation++
38
+ clearProtectedUrl()
39
+ })
40
+
41
+ const url = computed(() => protectedUrl.value ?? publicDelivery.url.value)
42
+ const error = computed(() => protectedError.value || publicDelivery.error.value)
43
+ const onError = () => {
44
+ if (props.load) protectedError.value = true
45
+ else publicDelivery.onError()
46
+ }
6
47
  </script>
7
48
 
8
49
  <template>
9
50
  <div class="flex column gap-1">
10
- <Image v-if="type === 'image'" :src="asset" />
51
+ <Image v-if="type === 'image' && (!load || url)" :src="load ? url : asset" @error="onError" />
11
52
  <video v-else-if="type === 'video' && url" :src="url" controls @error="onError" />
12
53
  <iframe v-else-if="type === 'pdf' && url" :src="url" :title="name || 'PDF preview'" referrerpolicy="no-referrer" />
13
54
  <Btn v-if="url" :href="url" target="_blank" rel="noopener noreferrer" value="Open / download" />
@@ -439,6 +439,10 @@ function getFieldProps(field: FieldBuilder, key: string) {
439
439
  baseProps.multiline = true
440
440
  }
441
441
 
442
+ if (field._type === 'datetime') {
443
+ baseProps.enableTime = true
444
+ }
445
+
442
446
  if (field._type === 'multiselect') {
443
447
  baseProps.multiselect = true
444
448
  }