@xenterprises/nuxt-x-marketing 1.11.0 → 1.13.0
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/CHANGELOG.md
CHANGED
|
@@ -30,6 +30,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
30
30
|
section and its sample product data were dropped with them. Every other section of the
|
|
31
31
|
demo page is unchanged.
|
|
32
32
|
|
|
33
|
+
## [1.13.0] - 2026-10-13
|
|
34
|
+
|
|
35
|
+
Opt-in; plain-string answers render byte-identical to 1.12.0.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **`XMarkFAQ` inline links in answers.** An answer may be `{ text, links }`: `text` holds
|
|
40
|
+
`{token}` placeholders and `links` maps each token to `{ label, to, target? }` (or an array,
|
|
41
|
+
addressed as `{0}`, `{1}`). Rendered as text plus `NuxtLink`s, never `v-html`, so HTML in the text
|
|
42
|
+
shows as text. Only `/`, `#`, `?`, `http(s)://`, `mailto:` and `tel:` targets become links (others
|
|
43
|
+
render the label as plain text); internal paths are same-tab; `target: '_blank'` adds
|
|
44
|
+
`rel="noopener noreferrer"`. Unknown tokens stay literal. Works in both layouts; a closed
|
|
45
|
+
accordion answer with links is `inert` so its links are not tabbable while hidden.
|
|
46
|
+
- `useFaqAnswer()` composable (`isRichAnswer`, `answerParts`, `answerPlainText`).
|
|
47
|
+
|
|
48
|
+
## [1.12.0] - 2026-10-13
|
|
49
|
+
|
|
50
|
+
Opt-in; the default (`aspectRatio="16/9"`, cropped with object-cover) renders byte-identical to 1.11.0.
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- **`XMarkMedia aspectRatio="auto"`**: the image renders at its natural ratio with no crop
|
|
55
|
+
(`block w-full h-auto`, no aspect-ratio style, no object-cover, no absolute positioning).
|
|
56
|
+
`img.width` / `img.height` are passed through as attributes in this mode to reserve space.
|
|
57
|
+
`aspect="auto"` is accepted as an alias (it only takes `"auto"`).
|
|
58
|
+
|
|
33
59
|
## [1.11.0] - 2026-10-12
|
|
34
60
|
|
|
35
61
|
Additive; menus with only flat children render byte-identical to 1.10.0.
|
|
@@ -38,6 +38,25 @@
|
|
|
38
38
|
}"
|
|
39
39
|
>
|
|
40
40
|
<div
|
|
41
|
+
v-if="isRichAnswer(item.answer)"
|
|
42
|
+
:inert="!openIndexes.includes(index) || undefined"
|
|
43
|
+
class="p-5 pt-0 text-neutral-600 dark:text-neutral-400 prose prose-neutral dark:prose-invert max-w-none"
|
|
44
|
+
>
|
|
45
|
+
<template
|
|
46
|
+
v-for="(part, pi) in answerParts(item.answer)"
|
|
47
|
+
:key="pi"
|
|
48
|
+
>
|
|
49
|
+
<NuxtLink
|
|
50
|
+
v-if="part.to"
|
|
51
|
+
:to="part.to"
|
|
52
|
+
:target="part.target"
|
|
53
|
+
:rel="part.rel"
|
|
54
|
+
>{{ part.label }}</NuxtLink>
|
|
55
|
+
<template v-else>{{ part.text }}</template>
|
|
56
|
+
</template>
|
|
57
|
+
</div>
|
|
58
|
+
<div
|
|
59
|
+
v-else
|
|
41
60
|
class="p-5 pt-0 text-neutral-600 dark:text-neutral-400 prose prose-neutral dark:prose-invert max-w-none"
|
|
42
61
|
>
|
|
43
62
|
{{ item.answer }}
|
|
@@ -63,7 +82,34 @@
|
|
|
63
82
|
</div>
|
|
64
83
|
|
|
65
84
|
<!-- Answers Column -->
|
|
66
|
-
<div
|
|
85
|
+
<div
|
|
86
|
+
v-if="hasRichAnswers"
|
|
87
|
+
class="answers-column space-y-4"
|
|
88
|
+
>
|
|
89
|
+
<div
|
|
90
|
+
v-for="(item, index) in items"
|
|
91
|
+
:key="index"
|
|
92
|
+
class="text-neutral-600 dark:text-neutral-400"
|
|
93
|
+
>
|
|
94
|
+
<template
|
|
95
|
+
v-for="(part, pi) in answerParts(item.answer)"
|
|
96
|
+
:key="pi"
|
|
97
|
+
>
|
|
98
|
+
<NuxtLink
|
|
99
|
+
v-if="part.to"
|
|
100
|
+
:to="part.to"
|
|
101
|
+
:target="part.target"
|
|
102
|
+
:rel="part.rel"
|
|
103
|
+
class="underline"
|
|
104
|
+
>{{ part.label }}</NuxtLink>
|
|
105
|
+
<template v-else>{{ part.text }}</template>
|
|
106
|
+
</template>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
<div
|
|
110
|
+
v-else
|
|
111
|
+
class="answers-column space-y-4"
|
|
112
|
+
>
|
|
67
113
|
<div
|
|
68
114
|
v-for="(item, index) in items"
|
|
69
115
|
:key="index"
|
|
@@ -77,8 +123,14 @@
|
|
|
77
123
|
</template>
|
|
78
124
|
|
|
79
125
|
<script setup>
|
|
126
|
+
import { useFaqAnswer } from "../../../../composables/useFaqAnswer";
|
|
127
|
+
|
|
80
128
|
const props = defineProps({
|
|
81
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Array of FAQ items [{ question, answer, open? }].
|
|
131
|
+
* answer is a plain string, or (1.13.0) { text, links } where text holds {tokens} and
|
|
132
|
+
* links maps each token to { label, to, target? }. Rendered as text + NuxtLinks (no v-html).
|
|
133
|
+
*/
|
|
82
134
|
items: {
|
|
83
135
|
type: Array,
|
|
84
136
|
default: () => [
|
|
@@ -106,6 +158,9 @@ const props = defineProps({
|
|
|
106
158
|
},
|
|
107
159
|
});
|
|
108
160
|
|
|
161
|
+
const { isRichAnswer, answerParts, answerPlainText } = useFaqAnswer();
|
|
162
|
+
const hasRichAnswers = computed(() => props.items.some((item) => isRichAnswer(item.answer)));
|
|
163
|
+
|
|
109
164
|
// Track which items are open
|
|
110
165
|
const openIndexes = ref([]);
|
|
111
166
|
|
|
@@ -158,7 +213,7 @@ function calculateHeights() {
|
|
|
158
213
|
// This is a simplified approach - in production, you might measure actual heights
|
|
159
214
|
props.items.forEach((item, index) => {
|
|
160
215
|
// Estimate height based on answer length (rough approximation)
|
|
161
|
-
const answerLength = item.answer
|
|
216
|
+
const answerLength = typeof item.answer === "string" ? item.answer.length : answerPlainText(item.answer).length;
|
|
162
217
|
// Average characters per line ~50, line height ~1.6, padding ~20px
|
|
163
218
|
const lines = Math.ceil(answerLength / 50);
|
|
164
219
|
const estimatedHeight = lines * 28 + 40; // ~28px per line + padding
|
|
@@ -2,6 +2,40 @@
|
|
|
2
2
|
<div :class="containerClasses">
|
|
3
3
|
<figure class="m-0">
|
|
4
4
|
<div
|
|
5
|
+
v-if="isAuto"
|
|
6
|
+
class="relative w-full overflow-hidden"
|
|
7
|
+
:class="roundedClass"
|
|
8
|
+
>
|
|
9
|
+
<picture v-if="img?.sources?.length">
|
|
10
|
+
<source
|
|
11
|
+
v-for="(source, i) in img.sources"
|
|
12
|
+
:key="i"
|
|
13
|
+
:srcset="source.srcset"
|
|
14
|
+
:type="source.type"
|
|
15
|
+
:media="source.media"
|
|
16
|
+
:sizes="source.sizes"
|
|
17
|
+
>
|
|
18
|
+
<img
|
|
19
|
+
:src="img.src"
|
|
20
|
+
:alt="img.alt || ''"
|
|
21
|
+
:width="img.width"
|
|
22
|
+
:height="img.height"
|
|
23
|
+
:loading="loading"
|
|
24
|
+
class="block w-full h-auto"
|
|
25
|
+
>
|
|
26
|
+
</picture>
|
|
27
|
+
<img
|
|
28
|
+
v-else
|
|
29
|
+
:src="img.src"
|
|
30
|
+
:alt="img.alt || ''"
|
|
31
|
+
:width="img.width"
|
|
32
|
+
:height="img.height"
|
|
33
|
+
:loading="loading"
|
|
34
|
+
class="block w-full h-auto"
|
|
35
|
+
>
|
|
36
|
+
</div>
|
|
37
|
+
<div
|
|
38
|
+
v-else
|
|
5
39
|
class="relative w-full overflow-hidden"
|
|
6
40
|
:class="roundedClass"
|
|
7
41
|
:style="aspectStyle"
|
|
@@ -59,11 +93,20 @@ const props = defineProps({
|
|
|
59
93
|
(Array.isArray(v.sources) &&
|
|
60
94
|
v.sources.every((x) => x && typeof x.srcset === "string"))),
|
|
61
95
|
},
|
|
62
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* CSS aspect-ratio, e.g. "16/9", "21/9", "4/3" (image is cropped to fill, object-cover).
|
|
98
|
+
* 1.12.0: "auto" shows the image at its natural ratio with no crop (w-full h-auto).
|
|
99
|
+
*/
|
|
63
100
|
aspectRatio: {
|
|
64
101
|
type: String,
|
|
65
102
|
default: "16/9",
|
|
66
|
-
validator: (v) => /^\d+(\.\d+)?\s*\/\s*\d+(\.\d+)?$/.test(v),
|
|
103
|
+
validator: (v) => v === "auto" || /^\d+(\.\d+)?\s*\/\s*\d+(\.\d+)?$/.test(v),
|
|
104
|
+
},
|
|
105
|
+
/** 1.12.0 alias: aspect="auto" is the same as aspectRatio="auto" (only "auto" is accepted). */
|
|
106
|
+
aspect: {
|
|
107
|
+
type: String,
|
|
108
|
+
default: "",
|
|
109
|
+
validator: (v) => v === "" || v === "auto",
|
|
67
110
|
},
|
|
68
111
|
/** Same values and classes as XMarkSection container */
|
|
69
112
|
container: {
|
|
@@ -112,6 +155,8 @@ const roundedClass = computed(() => {
|
|
|
112
155
|
return r[props.rounded];
|
|
113
156
|
});
|
|
114
157
|
|
|
158
|
+
const isAuto = computed(() => props.aspectRatio === "auto" || props.aspect === "auto");
|
|
159
|
+
|
|
115
160
|
const aspectStyle = computed(() => ({ aspectRatio: props.aspectRatio.replace(/\s+/g, " ") }));
|
|
116
161
|
|
|
117
162
|
const positionStyle = computed(() =>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useFaqAnswer (1.13.0) - safe inline links for XMarkFAQ answers.
|
|
3
|
+
*
|
|
4
|
+
* answer: "plain string" (rendered as before) or
|
|
5
|
+
* { text: "See our {guide} or {contact}.", links: { guide: { label, to, target? }, contact: {...} } }
|
|
6
|
+
* links may also be an array, addressed as {0}, {1}, ...
|
|
7
|
+
*
|
|
8
|
+
* Output is a list of text and link parts rendered with interpolation and NuxtLink; no HTML is
|
|
9
|
+
* ever parsed. Only "/", "#", "?", http(s):, mailto: and tel: targets become links; anything
|
|
10
|
+
* else (javascript:, data:, ...) renders as plain label text. Unknown tokens stay literal.
|
|
11
|
+
*/
|
|
12
|
+
const TOKEN = /\{([A-Za-z0-9_-]+)\}/g;
|
|
13
|
+
const SAFE = /^(\/(?!\/)|#|\?|https?:\/\/|mailto:|tel:)/i;
|
|
14
|
+
|
|
15
|
+
export function isRichAnswer(answer) {
|
|
16
|
+
return !!answer && typeof answer === "object" && typeof answer.text === "string";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function answerParts(answer) {
|
|
20
|
+
if (!isRichAnswer(answer)) return [{ text: answer == null ? "" : String(answer) }];
|
|
21
|
+
const links = answer.links || {};
|
|
22
|
+
const parts = [];
|
|
23
|
+
let last = 0;
|
|
24
|
+
for (const m of answer.text.matchAll(TOKEN)) {
|
|
25
|
+
const link = links[m[1]];
|
|
26
|
+
if (!link || typeof link.label !== "string") continue;
|
|
27
|
+
if (m.index > last) parts.push({ text: answer.text.slice(last, m.index) });
|
|
28
|
+
const to = typeof link.to === "string" ? link.to.trim() : "";
|
|
29
|
+
if (to && SAFE.test(to)) {
|
|
30
|
+
const target = link.target || undefined;
|
|
31
|
+
parts.push({
|
|
32
|
+
to,
|
|
33
|
+
label: link.label,
|
|
34
|
+
target,
|
|
35
|
+
rel: target === "_blank" ? "noopener noreferrer" : undefined,
|
|
36
|
+
});
|
|
37
|
+
} else {
|
|
38
|
+
parts.push({ text: link.label });
|
|
39
|
+
}
|
|
40
|
+
last = m.index + m[0].length;
|
|
41
|
+
}
|
|
42
|
+
if (last < answer.text.length) parts.push({ text: answer.text.slice(last) });
|
|
43
|
+
return parts;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function answerPlainText(answer) {
|
|
47
|
+
return answerParts(answer)
|
|
48
|
+
.map((p) => (p.to ? p.label : p.text))
|
|
49
|
+
.join("");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const useFaqAnswer = () => ({ isRichAnswer, answerParts, answerPlainText });
|