@xenterprises/nuxt-x-marketing 1.12.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,21 @@ 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
+
33
48
  ## [1.12.0] - 2026-10-13
34
49
 
35
50
  Opt-in; the default (`aspectRatio="16/9"`, cropped with object-cover) renders byte-identical to 1.11.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 class="answers-column space-y-4">
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
- /** Array of FAQ items [{ question, answer, open? }] */
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?.length || 0;
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
@@ -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 });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@xenterprises/nuxt-x-marketing",
3
3
  "license": "SEE LICENSE IN LICENSE",
4
4
  "type": "module",
5
- "version": "1.12.0",
5
+ "version": "1.13.0",
6
6
  "main": "./nuxt.config.ts",
7
7
  "scripts": {
8
8
  "dev": "nuxi dev .playground",