@duffcloudservices/cms 0.5.1 → 0.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duffcloudservices/cms",
3
- "version": "0.5.1",
3
+ "version": "0.7.1",
4
4
  "description": "Vue 3 composables and Vite plugins for DCS CMS integration",
5
5
  "type": "module",
6
6
  "exports": {
@@ -16,6 +16,10 @@
16
16
  "types": "./dist/editor/editorBridge.d.ts",
17
17
  "import": "./dist/editor/editorBridge.js"
18
18
  },
19
+ "./seo": {
20
+ "types": "./dist/seo/index.d.ts",
21
+ "import": "./dist/seo/index.js"
22
+ },
19
23
  "./components": {
20
24
  "import": "./src/components/PreviewRibbon.vue"
21
25
  },
@@ -57,6 +61,7 @@
57
61
  "@types/js-yaml": "^4.0.9",
58
62
  "@types/markdown-it": "^14.1.0",
59
63
  "@types/node": "^20.11.0",
64
+ "@vitejs/plugin-vue": "^6.0.1",
60
65
  "@vue/test-utils": "^2.4.0",
61
66
  "markdown-it": "^14.0.0",
62
67
  "tsup": "^8.0.0",
@@ -78,11 +83,11 @@
78
83
  "license": "MIT",
79
84
  "repository": {
80
85
  "type": "git",
81
- "url": "https://github.com/duffn/dcs"
86
+ "url": "https://github.com/NateDuff/dcs"
82
87
  },
83
88
  "homepage": "https://portal.duffcloudservices.com",
84
89
  "bugs": {
85
- "url": "https://github.com/duffn/dcs/issues"
90
+ "url": "https://github.com/NateDuff/dcs/issues"
86
91
  },
87
92
  "engines": {
88
93
  "node": ">=18.0.0"
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import ManagedImage from './ManagedImage.vue'
4
+
5
+ /**
6
+ * A CDN URL whose UUID/extension triggers responsive variant resolution
7
+ * (so a `<picture>` with `<source>` renders by default).
8
+ */
9
+ const ORIGINAL = 'https://files.duffcloudservices.com/kept/assets/hero/abc-123.jpg'
10
+
11
+ describe('ManagedImage self-healing fallback', () => {
12
+ it('renders a <picture> with <source> for a CDN image by default', () => {
13
+ const wrapper = mount(ManagedImage, {
14
+ props: {
15
+ pageSlug: 'home',
16
+ imageKey: 'hero.image',
17
+ fallbackSrc: ORIGINAL,
18
+ fallbackAlt: 'Hero',
19
+ context: 'hero',
20
+ },
21
+ })
22
+
23
+ expect(wrapper.find('picture').exists()).toBe(true)
24
+ expect(wrapper.find('source').exists()).toBe(true)
25
+ // Default <img> src points at a generated variant, not the original.
26
+ expect(wrapper.find('img').attributes('src')).toContain('abc-123-md.webp')
27
+ })
28
+
29
+ it('swaps to the original and removes <source> when the variant errors', async () => {
30
+ const wrapper = mount(ManagedImage, {
31
+ props: {
32
+ pageSlug: 'home',
33
+ imageKey: 'hero.image',
34
+ fallbackSrc: ORIGINAL,
35
+ fallbackAlt: 'Hero',
36
+ context: 'hero',
37
+ },
38
+ })
39
+
40
+ // Sanity: starts as a <picture> with variant <source>.
41
+ expect(wrapper.find('picture').exists()).toBe(true)
42
+ expect(wrapper.find('source').exists()).toBe(true)
43
+
44
+ // The 404'd fit-variant fires `error`.
45
+ await wrapper.find('img').trigger('error')
46
+
47
+ // The <picture>/<source> branch must drop out — a <picture> does NOT
48
+ // re-resolve after a <source> 404s, so the variant set has to be removed.
49
+ expect(wrapper.find('picture').exists()).toBe(false)
50
+ expect(wrapper.find('source').exists()).toBe(false)
51
+
52
+ // A plain <img> now renders the ORIGINAL url.
53
+ const img = wrapper.find('img')
54
+ expect(img.exists()).toBe(true)
55
+ expect(img.attributes('src')).toBe(ORIGINAL)
56
+ // The original is still exposed for the visual editor.
57
+ expect(img.attributes('data-dcs-image-url')).toBe(ORIGINAL)
58
+ })
59
+
60
+ it('renders a plain <img> at the original when `original` is set (no variants)', () => {
61
+ const wrapper = mount(ManagedImage, {
62
+ props: {
63
+ pageSlug: 'home',
64
+ imageKey: 'hero.image',
65
+ fallbackSrc: ORIGINAL,
66
+ fallbackAlt: 'Hero',
67
+ context: 'hero',
68
+ original: true,
69
+ },
70
+ })
71
+
72
+ expect(wrapper.find('picture').exists()).toBe(false)
73
+ expect(wrapper.find('img').attributes('src')).toBe(ORIGINAL)
74
+ })
75
+ })
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, useAttrs } from 'vue'
2
+ import { computed, ref, useAttrs } from 'vue'
3
3
  import { useResponsiveImage } from '../composables/useResponsiveImage'
4
4
  import { useTextContent } from '../composables/useTextContent'
5
5
  import type { ImageContext } from '@duffcloudservices/cms-core'
@@ -42,9 +42,25 @@ const image = useResponsiveImage({
42
42
  original: () => props.original,
43
43
  })
44
44
 
45
+ // Self-heal: a fit-variant (`-md`/`-lg`) may legitimately be absent when the
46
+ // source is smaller than the target dimension (see internal/imaging variants.go,
47
+ // which skips variants by DIMENSION). When the rendered variant 404s, fall back
48
+ // to the ORIGINAL url and drop the `<picture>`/`<source>` branch — a `<picture>`
49
+ // does NOT re-resolve after a `<source>` 404s, so the variant set must be removed,
50
+ // not just the `<img>` src swapped.
51
+ const failed = ref(false)
52
+ function onVariantError() {
53
+ if (!failed.value) {
54
+ failed.value = true
55
+ }
56
+ }
57
+
45
58
  const imgAttrs = computed(() => ({
46
59
  ...image.imgProps,
47
60
  ...attrs,
61
+ // When a variant has failed, render the original (also exposed as
62
+ // data-dcs-image-url for the visual editor) instead of the 404'd variant.
63
+ ...(failed.value ? { src: imageSrc.value } : {}),
48
64
  'data-dcs-image-key': props.imageKey,
49
65
  'data-dcs-image-url': imageSrc.value,
50
66
  'data-dcs-image-alt': imageAlt.value,
@@ -52,7 +68,7 @@ const imgAttrs = computed(() => ({
52
68
  </script>
53
69
 
54
70
  <template>
55
- <picture v-if="image.hasVariants && !original">
71
+ <picture v-if="image.hasVariants && !original && !failed">
56
72
  <source
57
73
  v-for="source in image.sources"
58
74
  :key="source.type"
@@ -60,7 +76,7 @@ const imgAttrs = computed(() => ({
60
76
  :type="source.type"
61
77
  :sizes="source.sizes"
62
78
  />
63
- <img v-bind="imgAttrs" />
79
+ <img v-bind="imgAttrs" @error="onVariantError" />
64
80
  </picture>
65
81
  <img v-else v-bind="imgAttrs" />
66
82
  </template>
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import ResponsiveImage from './ResponsiveImage.vue'
4
+
5
+ const ORIGINAL = 'https://files.duffcloudservices.com/kept/assets/hero/abc-123.jpg'
6
+
7
+ describe('ResponsiveImage self-healing fallback', () => {
8
+ it('renders a <picture> with <source> for a CDN image by default', () => {
9
+ const wrapper = mount(ResponsiveImage, {
10
+ props: { src: ORIGINAL, alt: 'Hero', context: 'hero' },
11
+ })
12
+
13
+ expect(wrapper.find('picture').exists()).toBe(true)
14
+ expect(wrapper.find('source').exists()).toBe(true)
15
+ expect(wrapper.find('img').attributes('src')).toContain('abc-123-md.webp')
16
+ })
17
+
18
+ it('swaps to props.src and removes <source> when the variant errors', async () => {
19
+ const wrapper = mount(ResponsiveImage, {
20
+ props: { src: ORIGINAL, alt: 'Hero', context: 'hero', class: 'object-cover' },
21
+ })
22
+
23
+ expect(wrapper.find('picture').exists()).toBe(true)
24
+ expect(wrapper.find('source').exists()).toBe(true)
25
+
26
+ await wrapper.find('img').trigger('error')
27
+
28
+ // <picture>/<source> must be neutralized so the original can display.
29
+ expect(wrapper.find('picture').exists()).toBe(false)
30
+ expect(wrapper.find('source').exists()).toBe(false)
31
+
32
+ const img = wrapper.find('img')
33
+ expect(img.exists()).toBe(true)
34
+ expect(img.attributes('src')).toBe(ORIGINAL)
35
+ // The class binding survives the fallback render.
36
+ expect(img.classes()).toContain('object-cover')
37
+ })
38
+
39
+ it('renders a plain <img> at props.src when `original` is set', () => {
40
+ const wrapper = mount(ResponsiveImage, {
41
+ props: { src: ORIGINAL, alt: 'Hero', context: 'hero', original: true },
42
+ })
43
+
44
+ expect(wrapper.find('picture').exists()).toBe(false)
45
+ expect(wrapper.find('img').attributes('src')).toBe(ORIGINAL)
46
+ })
47
+ })
@@ -13,7 +13,7 @@
13
13
  * />
14
14
  * ```
15
15
  */
16
- import { computed, useAttrs } from 'vue'
16
+ import { computed, ref, useAttrs } from 'vue'
17
17
  import { useResponsiveImage } from '../composables/useResponsiveImage'
18
18
  import type { ImageContext } from '@duffcloudservices/cms-core'
19
19
 
@@ -42,15 +42,27 @@ const image = useResponsiveImage({
42
42
  original: () => props.original,
43
43
  })
44
44
 
45
+ // Self-heal: when the rendered fit-variant (`-md`/`-lg`) 404s — legitimately
46
+ // absent for sources smaller than the target dimension — fall back to the
47
+ // ORIGINAL `props.src` and drop the `<picture>`/`<source>` branch. A `<picture>`
48
+ // does NOT re-resolve after a `<source>` 404s, so the variant set must be removed.
49
+ const failed = ref(false)
50
+ function onVariantError() {
51
+ if (!failed.value) {
52
+ failed.value = true
53
+ }
54
+ }
55
+
45
56
  const attrs = useAttrs()
46
57
  const imgAttrs = computed(() => ({
47
58
  ...image.imgProps,
48
59
  ...attrs,
60
+ ...(failed.value ? { src: props.src } : {}),
49
61
  }))
50
62
  </script>
51
63
 
52
64
  <template>
53
- <picture v-if="image.hasVariants && !original">
65
+ <picture v-if="image.hasVariants && !original && !failed">
54
66
  <source
55
67
  v-for="source in image.sources"
56
68
  :key="source.type"
@@ -58,7 +70,7 @@ const imgAttrs = computed(() => ({
58
70
  :type="source.type"
59
71
  :sizes="source.sizes"
60
72
  />
61
- <img v-bind="imgAttrs" :class="props.class" />
73
+ <img v-bind="imgAttrs" :class="props.class" @error="onVariantError" />
62
74
  </picture>
63
75
  <img v-else v-bind="imgAttrs" :class="props.class" />
64
76
  </template>