@stackonward/cli 1.2.1 → 1.3.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 (36) hide show
  1. package/dist/index.js +17 -10
  2. package/dist/index.js.map +1 -1
  3. package/dist/resources/components/nuxt/site-source/app/app.vue +9 -6
  4. package/dist/resources/components/nuxt/site-source/app/assets/css/site-shell.css +268 -33
  5. package/dist/resources/components/nuxt/site-source/app/components/site/ApplicationHeader.vue +7 -4
  6. package/dist/resources/components/nuxt/site-source/app/components/site/ApplicationShell.vue +1 -1
  7. package/dist/resources/components/nuxt/site-source/app/components/site/ApplicationSidebar.vue +4 -3
  8. package/dist/resources/components/nuxt/site-source/app/components/site/BackToTop.vue +4 -12
  9. package/dist/resources/components/nuxt/site-source/app/components/site/ConsentSettingsButton.vue +24 -0
  10. package/dist/resources/components/nuxt/site-source/app/components/site/ContentArticlePage.vue +18 -6
  11. package/dist/resources/components/nuxt/site-source/app/components/site/ContentListPage.vue +2 -2
  12. package/dist/resources/components/nuxt/site-source/app/components/site/CookieConsent.vue +44 -4
  13. package/dist/resources/components/nuxt/site-source/app/components/site/LocaleSwitcher.vue +57 -0
  14. package/dist/resources/components/nuxt/site-source/app/components/site/MarketingFooter.vue +11 -9
  15. package/dist/resources/components/nuxt/site-source/app/components/site/MarketingHeader.vue +31 -19
  16. package/dist/resources/components/nuxt/site-source/app/components/site/MarketingShell.vue +9 -9
  17. package/dist/resources/components/nuxt/site-source/app/components/site/SiteBrand.vue +3 -2
  18. package/dist/resources/components/nuxt/site-source/app/components/site/StaticPage.vue +2 -1
  19. package/dist/resources/components/nuxt/site-source/app/components/site/ThemeSwitcher.vue +63 -0
  20. package/dist/resources/components/nuxt/site-source/app/components/site/ToastViewport.vue +3 -2
  21. package/dist/resources/components/nuxt/site-source/app/composables/useConsent.ts +299 -51
  22. package/dist/resources/components/nuxt/site-source/app/composables/useNavigation.ts +49 -6
  23. package/dist/resources/components/nuxt/site-source/app/composables/useSeo.ts +4 -2
  24. package/dist/resources/components/nuxt/site-source/app/composables/useSiteShell.ts +29 -11
  25. package/dist/resources/components/nuxt/site-source/app/plugins/consent.ts +43 -0
  26. package/dist/resources/components/nuxt/site-source/app/types/site-shell.ts +146 -59
  27. package/dist/resources/components/registry.json +30 -0
  28. package/dist/resources/registry.json +3 -3
  29. package/dist/resources/templates/nuxt/app/app.config.ts +83 -38
  30. package/dist/resources/templates/nuxt/app/i18n/locales/en.json +124 -0
  31. package/dist/resources/templates/nuxt/app/pages/legal/cookies.vue +68 -0
  32. package/dist/resources/templates/nuxt/nuxt.config.ts +16 -0
  33. package/dist/resources/templates/nuxt/package.json +2 -2
  34. package/dist/resources/templates/nuxt/test/unit/consent.test.ts +289 -0
  35. package/dist/resources/templates/nuxt/vitest.config.ts +6 -0
  36. package/package.json +3 -3
@@ -1,71 +1,319 @@
1
- export type ConsentValue = "accepted" | "rejected";
2
-
3
- export function useConsent(storageKey?: string) {
4
- const site = useSiteShell();
5
- const analytics = useAnalytics();
6
- const analyticsConsent = useAnalyticsConsent();
7
- const resolvedStorageKey = computed(() => storageKey ?? site.value.consent.storageKey);
8
- const value = ref<ConsentValue | null>(null);
9
- const ready = ref(false);
10
-
11
- function applyStoredValue(stored: string | null): void {
12
- value.value = stored === "accepted" || stored === "rejected" ? stored : null;
13
- if (value.value === "accepted") {
14
- analyticsConsent.grant();
15
- } else if (value.value === "rejected") {
16
- analyticsConsent.deny();
17
- } else {
18
- analyticsConsent.reset();
1
+ import { computed, readonly } from "vue";
2
+ import type { ComputedRef, Ref } from "vue";
3
+ import type {
4
+ ConsentInitializationResult,
5
+ ConsentMutationResult,
6
+ ConsentState,
7
+ ConsentValue,
8
+ SiteConsentController,
9
+ SiteShellConsentConfig,
10
+ } from "~/types/site-shell";
11
+
12
+ const CONSENT_RECORD_VERSION = 1;
13
+ const MAX_TIMER_DELAY = 2_147_483_647;
14
+
15
+ interface ConsentRecord {
16
+ readonly version: typeof CONSENT_RECORD_VERSION;
17
+ readonly policyRevision: string;
18
+ readonly value: ConsentValue;
19
+ readonly expiresAt: string;
20
+ }
21
+
22
+ interface ConsentAnalyticsBridge {
23
+ readonly available: () => boolean;
24
+ readonly grant: () => void;
25
+ readonly deny: () => void;
26
+ readonly reset: () => void;
27
+ }
28
+
29
+ interface CreateSiteConsentControllerOptions {
30
+ readonly state: Ref<ConsentState>;
31
+ readonly config: Readonly<ComputedRef<SiteShellConsentConfig>>;
32
+ readonly analytics: ConsentAnalyticsBridge;
33
+ }
34
+
35
+ /** 在应用生命周期内协调同意状态、持久化和分析开关。 */
36
+ export function createSiteConsentController(
37
+ options: CreateSiteConsentControllerOptions,
38
+ ): SiteConsentController {
39
+ let initialized = false;
40
+ let disposed = false;
41
+ let expiryTimer: ReturnType<typeof setTimeout> | undefined;
42
+ let initializationResult: ConsentInitializationResult = {
43
+ status: "undecided",
44
+ cleanup: "not-required",
45
+ };
46
+
47
+ const visible = computed(
48
+ () =>
49
+ options.config.value.enabled &&
50
+ options.analytics.available() &&
51
+ options.state.value.ready &&
52
+ (options.state.value.decision === null || options.state.value.settingsOpen),
53
+ );
54
+
55
+ function assertAvailable(
56
+ operation: ConsentLifecycleError["operation"],
57
+ requiresReady = true,
58
+ ): void {
59
+ if (disposed || (requiresReady && !options.state.value.ready)) {
60
+ throw new ConsentLifecycleError(operation);
19
61
  }
20
62
  }
21
63
 
22
- function handleStorage(event: StorageEvent): void {
23
- if (event.key === resolvedStorageKey.value) {
24
- applyStoredValue(event.newValue);
64
+ function cancelExpiry(): void {
65
+ if (expiryTimer !== undefined) {
66
+ clearTimeout(expiryTimer);
67
+ expiryTimer = undefined;
25
68
  }
26
69
  }
27
70
 
28
- onMounted(() => {
71
+ function scheduleExpiry(expiresAt: number): void {
72
+ cancelExpiry();
73
+ const remaining = expiresAt - Date.now();
74
+ if (remaining <= 0) {
75
+ expireCurrentDecision();
76
+ return;
77
+ }
78
+ expiryTimer = setTimeout(() => restoreStoredDecision(), Math.min(remaining, MAX_TIMER_DELAY));
79
+ }
80
+
81
+ function parseConsentRecord(raw: string): ConsentRecord | null {
82
+ let candidate: unknown;
29
83
  try {
30
- applyStoredValue(window.localStorage.getItem(resolvedStorageKey.value));
84
+ candidate = JSON.parse(raw);
31
85
  } catch {
32
- value.value = null;
33
- analyticsConsent.reset();
86
+ return null;
87
+ }
88
+ if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
89
+ return null;
34
90
  }
35
- window.addEventListener("storage", handleStorage);
36
- ready.value = true;
37
- });
91
+ const record = candidate as Record<string, unknown>;
92
+ if (
93
+ record.version !== CONSENT_RECORD_VERSION ||
94
+ record.policyRevision !== options.config.value.policyRevision ||
95
+ (record.value !== "accepted" && record.value !== "rejected") ||
96
+ typeof record.expiresAt !== "string"
97
+ ) {
98
+ return null;
99
+ }
100
+ const expiresAt = Date.parse(record.expiresAt);
101
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
102
+ return null;
103
+ }
104
+ return {
105
+ version: CONSENT_RECORD_VERSION,
106
+ policyRevision: record.policyRevision,
107
+ value: record.value,
108
+ expiresAt: record.expiresAt,
109
+ };
110
+ }
38
111
 
39
- onBeforeUnmount(() => window.removeEventListener("storage", handleStorage));
112
+ function updateUndecided(
113
+ persistence: ConsentState["persistence"],
114
+ error: ConsentState["error"],
115
+ ): void {
116
+ cancelExpiry();
117
+ options.analytics.reset();
118
+ options.state.value = {
119
+ decision: null,
120
+ ready: true,
121
+ settingsOpen: false,
122
+ persistence,
123
+ error,
124
+ };
125
+ }
40
126
 
41
- const visible = computed(
42
- () =>
43
- site.value.consent.enabled &&
44
- analytics.status.value !== "disabled" &&
45
- ready.value &&
46
- value.value === null,
47
- );
127
+ function clearInvalidRecord(): ConsentInitializationResult {
128
+ try {
129
+ window.localStorage.removeItem(options.config.value.storageKey);
130
+ updateUndecided("cleared", null);
131
+ return { status: "undecided", cleanup: "cleared" };
132
+ } catch {
133
+ updateUndecided("cleanup-failed", "clear-failed");
134
+ return { status: "undecided", cleanup: "failed", error: "clear-failed" };
135
+ }
136
+ }
48
137
 
49
- function setConsent(next: ConsentValue): void {
50
- value.value = next;
51
- if (import.meta.client) {
52
- try {
53
- window.localStorage.setItem(resolvedStorageKey.value, next);
54
- } catch {
55
- // 浏览器拒绝持久化时,本次会话的明确选择仍然生效。
56
- }
57
- if (next === "accepted") {
58
- analyticsConsent.grant();
59
- } else {
60
- analyticsConsent.deny();
138
+ function applyRecord(record: ConsentRecord): ConsentInitializationResult {
139
+ const expiresAt = Date.parse(record.expiresAt);
140
+ if (record.value === "accepted") {
141
+ options.analytics.grant();
142
+ } else {
143
+ options.analytics.deny();
144
+ }
145
+ options.state.value = {
146
+ decision: record.value,
147
+ ready: true,
148
+ settingsOpen: false,
149
+ persistence: "persisted",
150
+ error: null,
151
+ };
152
+ scheduleExpiry(expiresAt);
153
+ return { status: "restored", value: record.value };
154
+ }
155
+
156
+ function restoreStoredDecision(): ConsentInitializationResult {
157
+ let raw: string | null;
158
+ try {
159
+ raw = window.localStorage.getItem(options.config.value.storageKey);
160
+ } catch {
161
+ updateUndecided("none", "read-failed");
162
+ return { status: "storage-unavailable", error: "read-failed" };
163
+ }
164
+ if (raw === null) {
165
+ updateUndecided("none", null);
166
+ return { status: "undecided", cleanup: "not-required" };
167
+ }
168
+ const record = parseConsentRecord(raw);
169
+ return record ? applyRecord(record) : clearInvalidRecord();
170
+ }
171
+
172
+ function expireCurrentDecision(): void {
173
+ initializationResult = restoreStoredDecision();
174
+ }
175
+
176
+ function onStorage(event: StorageEvent): void {
177
+ if (event.key === options.config.value.storageKey) {
178
+ initializationResult = restoreStoredDecision();
179
+ }
180
+ }
181
+
182
+ function onVisibilityChange(): void {
183
+ if (document.visibilityState === "visible") {
184
+ initializationResult = restoreStoredDecision();
185
+ }
186
+ }
187
+
188
+ function initialize(): ConsentInitializationResult {
189
+ assertAvailable("initialize", false);
190
+ if (initialized) {
191
+ return initializationResult;
192
+ }
193
+ initialized = true;
194
+ initializationResult = restoreStoredDecision();
195
+ window.addEventListener("storage", onStorage);
196
+ document.addEventListener("visibilitychange", onVisibilityChange);
197
+ return initializationResult;
198
+ }
199
+
200
+ function persist(next: ConsentValue): ConsentMutationResult {
201
+ assertAvailable(next === "accepted" ? "accept" : "reject");
202
+ cancelExpiry();
203
+ if (next === "accepted") {
204
+ options.analytics.grant();
205
+ } else {
206
+ options.analytics.deny();
207
+ }
208
+
209
+ const expiresAt = new Date(
210
+ Date.now() + options.config.value.retentionDays * 24 * 60 * 60 * 1000,
211
+ );
212
+ const record: ConsentRecord = {
213
+ version: CONSENT_RECORD_VERSION,
214
+ policyRevision: options.config.value.policyRevision,
215
+ value: next,
216
+ expiresAt: expiresAt.toISOString(),
217
+ };
218
+
219
+ try {
220
+ window.localStorage.setItem(options.config.value.storageKey, JSON.stringify(record));
221
+ options.state.value = {
222
+ decision: next,
223
+ ready: true,
224
+ settingsOpen: false,
225
+ persistence: "persisted",
226
+ error: null,
227
+ };
228
+ scheduleExpiry(expiresAt.getTime());
229
+ return { status: "persisted", value: next };
230
+ } catch {
231
+ if (next === "rejected") {
232
+ try {
233
+ window.localStorage.removeItem(options.config.value.storageKey);
234
+ options.state.value = {
235
+ decision: next,
236
+ ready: true,
237
+ settingsOpen: true,
238
+ persistence: "cleared",
239
+ error: "write-failed",
240
+ };
241
+ return { status: "cleared", value: next, error: "write-failed" };
242
+ } catch {
243
+ options.state.value = {
244
+ decision: next,
245
+ ready: true,
246
+ settingsOpen: true,
247
+ persistence: "session-only",
248
+ error: "write-and-clear-failed",
249
+ };
250
+ return {
251
+ status: "session-only",
252
+ value: next,
253
+ error: "write-and-clear-failed",
254
+ };
255
+ }
61
256
  }
257
+ options.state.value = {
258
+ decision: next,
259
+ ready: true,
260
+ settingsOpen: true,
261
+ persistence: "session-only",
262
+ error: "write-failed",
263
+ };
264
+ return { status: "session-only", value: next, error: "write-failed" };
265
+ }
266
+ }
267
+
268
+ function openSettings(): void {
269
+ assertAvailable("open-settings");
270
+ options.state.value.settingsOpen = true;
271
+ }
272
+
273
+ function closeSettings(): void {
274
+ assertAvailable("close-settings");
275
+ if (options.state.value.decision === null) {
276
+ throw new ConsentLifecycleError("close-settings");
277
+ }
278
+ options.state.value.settingsOpen = false;
279
+ }
280
+
281
+ function dispose(): void {
282
+ if (disposed) return;
283
+ disposed = true;
284
+ cancelExpiry();
285
+ if (initialized) {
286
+ window.removeEventListener("storage", onStorage);
287
+ document.removeEventListener("visibilitychange", onVisibilityChange);
62
288
  }
63
289
  }
64
290
 
65
291
  return {
66
- value: readonly(value),
292
+ state: readonly(options.state),
67
293
  visible,
68
- accept: () => setConsent("accepted"),
69
- reject: () => setConsent("rejected"),
294
+ initialize,
295
+ accept: () => persist("accepted"),
296
+ reject: () => persist("rejected"),
297
+ openSettings,
298
+ closeSettings,
299
+ dispose,
300
+ };
301
+ }
302
+
303
+ export class ConsentLifecycleError extends Error {
304
+ readonly operation: "initialize" | "accept" | "reject" | "open-settings" | "close-settings";
305
+
306
+ constructor(operation: ConsentLifecycleError["operation"]) {
307
+ super(`Consent operation is unavailable: ${operation}`);
308
+ this.name = "ConsentLifecycleError";
309
+ this.operation = operation;
310
+ }
311
+ }
312
+
313
+ /** 返回 Nuxt 应用提供的唯一同意控制器。 */
314
+ export function useConsent(): SiteConsentController {
315
+ const nuxtApp = useNuxtApp() as ReturnType<typeof useNuxtApp> & {
316
+ readonly $siteConsent: SiteConsentController;
70
317
  };
318
+ return nuxtApp.$siteConsent;
71
319
  }
@@ -1,11 +1,54 @@
1
- export function useNavigation() {
1
+ import type {
2
+ AppNavigationItem,
3
+ LocalizedNavigationItemConfig,
4
+ MarketingNavigationItem,
5
+ ResolvedSiteNavigation,
6
+ } from "~/types/site-shell";
7
+
8
+ /** 将产品配置中的资源键投影为当前语言可渲染的导航。 */
9
+ export function useNavigation(): ResolvedSiteNavigation {
2
10
  const site = useSiteShell();
11
+ const { t } = useI18n();
12
+
13
+ function resolveMarketingItem(item: LocalizedNavigationItemConfig): MarketingNavigationItem {
14
+ return {
15
+ label: t(item.labelKey),
16
+ ...(item.to === undefined ? {} : { to: item.to }),
17
+ ...(item.external === undefined ? {} : { external: item.external }),
18
+ ...(item.descriptionKey === undefined ? {} : { description: t(item.descriptionKey) }),
19
+ ...(item.children === undefined ? {} : { children: item.children.map(resolveMarketingItem) }),
20
+ };
21
+ }
3
22
 
4
23
  return {
5
- marketingNav: computed(() => site.value.navigation.marketing),
6
- appNavGroups: computed(() => site.value.navigation.app),
7
- footerGroups: computed(() => site.value.navigation.footer),
8
- legalLinks: computed(() => site.value.navigation.legal),
9
- socialLinks: computed(() => site.value.navigation.social),
24
+ marketingNav: computed(() => site.value.navigation.marketing.map(resolveMarketingItem)),
25
+ appNavGroups: computed(() =>
26
+ site.value.navigation.app.map((group) => ({
27
+ ...(group.labelKey === undefined ? {} : { label: t(group.labelKey) }),
28
+ items: group.items.map(
29
+ (item): AppNavigationItem => ({
30
+ label: t(item.labelKey),
31
+ to: item.to,
32
+ ...(item.icon === undefined ? {} : { icon: item.icon }),
33
+ ...(item.badge === undefined ? {} : { badge: item.badge }),
34
+ }),
35
+ ),
36
+ })),
37
+ ),
38
+ footerGroups: computed(() =>
39
+ site.value.navigation.footer.map((group) => ({
40
+ title: t(group.titleKey),
41
+ links: group.links.map(resolveMarketingItem),
42
+ })),
43
+ ),
44
+ legalLinks: computed(() => site.value.navigation.legal.map(resolveMarketingItem)),
45
+ socialLinks: computed(() =>
46
+ site.value.navigation.social.map((link) => ({
47
+ label: t(link.labelKey),
48
+ href: link.href,
49
+ })),
50
+ ),
51
+ primaryActionLabel: computed(() => t(site.value.primaryActionLabelKey)),
52
+ secondaryActionLabel: computed(() => t(site.value.secondaryActionLabelKey)),
10
53
  };
11
54
  }
@@ -15,14 +15,16 @@ function joinUrl(baseUrl: string, path: string): string {
15
15
 
16
16
  export function useSeo(input: SeoInput): void {
17
17
  const site = useSiteShell();
18
+ const { t } = useI18n();
18
19
  const route = useRoute();
19
20
  const canonical = computed(() => joinUrl(site.value.url, input.canonicalPath ?? route.path));
21
+ const description = computed(() => input.description ?? t(site.value.descriptionKey));
20
22
 
21
23
  useSeoMeta({
22
24
  title: input.title,
23
- description: input.description ?? site.value.description,
25
+ description,
24
26
  ogTitle: input.title,
25
- ogDescription: input.description ?? site.value.description,
27
+ ogDescription: description,
26
28
  ogImage: input.image,
27
29
  robots: input.robots,
28
30
  });
@@ -1,23 +1,39 @@
1
- import type { SiteShellConfig } from "~/types/site-shell";
1
+ import type { ComputedRef } from "vue";
2
+ import type {
3
+ SiteShellConfig,
4
+ SiteShellConsentConfig,
5
+ StorageDisclosureConfig,
6
+ } from "~/types/site-shell";
7
+
8
+ type SiteShellConfigInput = Omit<
9
+ Partial<SiteShellConfig>,
10
+ "consent" | "storageDisclosures" | "navigation" | "content"
11
+ > & {
12
+ readonly consent?: Partial<SiteShellConsentConfig>;
13
+ readonly storageDisclosures?: readonly StorageDisclosureConfig[];
14
+ readonly navigation?: Partial<SiteShellConfig["navigation"]>;
15
+ readonly content?: Partial<SiteShellConfig["content"]>;
16
+ };
2
17
 
3
18
  const DEFAULT_SITE_SHELL_CONFIG: SiteShellConfig = {
4
19
  name: "Product Studio",
5
- description: "Premium product imagery and ad video production workspace.",
20
+ descriptionKey: "site.description",
6
21
  url: "",
7
22
  defaultLocale: "en",
8
23
  homePath: "/",
9
24
  appPath: "/app",
10
25
  logoText: "Product Studio",
11
- primaryActionLabel: "Start creating",
12
- secondaryActionLabel: "Sign in",
26
+ primaryActionLabelKey: "actions.start",
27
+ secondaryActionLabelKey: "actions.signIn",
13
28
  secondaryActionPath: "/app",
14
29
  consent: {
15
30
  enabled: true,
16
- storageKey: "site-consent",
17
- message: "This site can store optional preferences on this device.",
18
- acceptLabel: "Allow",
19
- rejectLabel: "Decline",
31
+ storageKey: "site-analytics-consent-v1",
32
+ policyRevision: "1",
33
+ retentionDays: 180,
34
+ policyPath: "/legal/cookies",
20
35
  },
36
+ storageDisclosures: [],
21
37
  navigation: {
22
38
  marketing: [],
23
39
  app: [],
@@ -33,7 +49,7 @@ const DEFAULT_SITE_SHELL_CONFIG: SiteShellConfig = {
33
49
  },
34
50
  };
35
51
 
36
- function mergeSiteShellConfig(input: Partial<SiteShellConfig> | undefined): SiteShellConfig {
52
+ function mergeSiteShellConfig(input: SiteShellConfigInput | undefined): SiteShellConfig {
37
53
  return {
38
54
  ...DEFAULT_SITE_SHELL_CONFIG,
39
55
  ...input,
@@ -41,6 +57,7 @@ function mergeSiteShellConfig(input: Partial<SiteShellConfig> | undefined): Site
41
57
  ...DEFAULT_SITE_SHELL_CONFIG.consent,
42
58
  ...input?.consent,
43
59
  },
60
+ storageDisclosures: input?.storageDisclosures ?? DEFAULT_SITE_SHELL_CONFIG.storageDisclosures,
44
61
  navigation: {
45
62
  ...DEFAULT_SITE_SHELL_CONFIG.navigation,
46
63
  ...input?.navigation,
@@ -52,7 +69,8 @@ function mergeSiteShellConfig(input: Partial<SiteShellConfig> | undefined): Site
52
69
  };
53
70
  }
54
71
 
55
- export function useSiteShell() {
56
- const appConfig = useAppConfig() as { siteShell?: Partial<SiteShellConfig> };
72
+ /** 返回当前 Nuxt 应用拥有的站点配置只读视图。 */
73
+ export function useSiteShell(): Readonly<ComputedRef<SiteShellConfig>> {
74
+ const appConfig = useAppConfig() as { readonly siteShell?: SiteShellConfigInput };
57
75
  return computed(() => mergeSiteShellConfig(appConfig.siteShell));
58
76
  }
@@ -0,0 +1,43 @@
1
+ import type { ConsentState } from "~/types/site-shell";
2
+
3
+ export default defineNuxtPlugin({
4
+ name: "site:consent",
5
+ dependsOn: ["stackonward:analytics"],
6
+ setup(nuxtApp) {
7
+ const site = useSiteShell();
8
+ const analytics = useAnalytics();
9
+ const analyticsConsent = useAnalyticsConsent();
10
+ const state = useState<ConsentState>("site:consent", () => ({
11
+ decision: null,
12
+ ready: false,
13
+ settingsOpen: false,
14
+ persistence: "none",
15
+ error: null,
16
+ }));
17
+ const controller = createSiteConsentController({
18
+ state,
19
+ config: computed(() => site.value.consent),
20
+ analytics: {
21
+ available: () => analytics.status.value !== "disabled",
22
+ grant: () => {
23
+ analyticsConsent.grant();
24
+ },
25
+ deny: () => {
26
+ analyticsConsent.deny();
27
+ },
28
+ reset: () => {
29
+ analyticsConsent.reset();
30
+ },
31
+ },
32
+ });
33
+
34
+ if (import.meta.client) {
35
+ onNuxtReady(() => {
36
+ controller.initialize();
37
+ });
38
+ nuxtApp.vueApp.onUnmount(() => controller.dispose());
39
+ }
40
+
41
+ return { provide: { siteConsent: controller } };
42
+ },
43
+ });