@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.
- package/dist/index.js +17 -10
- package/dist/index.js.map +1 -1
- package/dist/resources/components/nuxt/site-source/app/app.vue +9 -6
- package/dist/resources/components/nuxt/site-source/app/assets/css/site-shell.css +268 -33
- package/dist/resources/components/nuxt/site-source/app/components/site/ApplicationHeader.vue +7 -4
- package/dist/resources/components/nuxt/site-source/app/components/site/ApplicationShell.vue +1 -1
- package/dist/resources/components/nuxt/site-source/app/components/site/ApplicationSidebar.vue +4 -3
- package/dist/resources/components/nuxt/site-source/app/components/site/BackToTop.vue +4 -12
- package/dist/resources/components/nuxt/site-source/app/components/site/ConsentSettingsButton.vue +24 -0
- package/dist/resources/components/nuxt/site-source/app/components/site/ContentArticlePage.vue +18 -6
- package/dist/resources/components/nuxt/site-source/app/components/site/ContentListPage.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/site/CookieConsent.vue +44 -4
- package/dist/resources/components/nuxt/site-source/app/components/site/LocaleSwitcher.vue +57 -0
- package/dist/resources/components/nuxt/site-source/app/components/site/MarketingFooter.vue +11 -9
- package/dist/resources/components/nuxt/site-source/app/components/site/MarketingHeader.vue +31 -19
- package/dist/resources/components/nuxt/site-source/app/components/site/MarketingShell.vue +9 -9
- package/dist/resources/components/nuxt/site-source/app/components/site/SiteBrand.vue +3 -2
- package/dist/resources/components/nuxt/site-source/app/components/site/StaticPage.vue +2 -1
- package/dist/resources/components/nuxt/site-source/app/components/site/ThemeSwitcher.vue +63 -0
- package/dist/resources/components/nuxt/site-source/app/components/site/ToastViewport.vue +3 -2
- package/dist/resources/components/nuxt/site-source/app/composables/useConsent.ts +299 -51
- package/dist/resources/components/nuxt/site-source/app/composables/useNavigation.ts +49 -6
- package/dist/resources/components/nuxt/site-source/app/composables/useSeo.ts +4 -2
- package/dist/resources/components/nuxt/site-source/app/composables/useSiteShell.ts +29 -11
- package/dist/resources/components/nuxt/site-source/app/plugins/consent.ts +43 -0
- package/dist/resources/components/nuxt/site-source/app/types/site-shell.ts +146 -59
- package/dist/resources/components/registry.json +30 -0
- package/dist/resources/registry.json +3 -3
- package/dist/resources/templates/nuxt/app/app.config.ts +83 -38
- package/dist/resources/templates/nuxt/app/i18n/locales/en.json +124 -0
- package/dist/resources/templates/nuxt/app/pages/legal/cookies.vue +68 -0
- package/dist/resources/templates/nuxt/nuxt.config.ts +16 -0
- package/dist/resources/templates/nuxt/package.json +2 -2
- package/dist/resources/templates/nuxt/test/unit/consent.test.ts +289 -0
- package/dist/resources/templates/nuxt/vitest.config.ts +6 -0
- package/package.json +3 -3
|
@@ -1,5 +1,129 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"close": "Close"
|
|
4
|
+
},
|
|
5
|
+
"site": {
|
|
6
|
+
"region": "Site",
|
|
7
|
+
"home": "Home",
|
|
8
|
+
"backToTop": "Back to top",
|
|
9
|
+
"skipToContent": "Skip to content",
|
|
10
|
+
"openMarketingSite": "View site",
|
|
11
|
+
"description": "A premium product media workspace for generating product images, campaign sets, and short-form ad videos."
|
|
12
|
+
},
|
|
13
|
+
"actions": {
|
|
14
|
+
"start": "Start creating",
|
|
15
|
+
"signIn": "Sign in"
|
|
16
|
+
},
|
|
17
|
+
"navigation": {
|
|
18
|
+
"primary": "Primary navigation",
|
|
19
|
+
"mobilePrimary": "Mobile primary navigation",
|
|
20
|
+
"application": "Application navigation",
|
|
21
|
+
"toggle": "Toggle navigation",
|
|
22
|
+
"toggleSidebar": "Toggle sidebar",
|
|
23
|
+
"groups": {
|
|
24
|
+
"production": "Production"
|
|
25
|
+
},
|
|
26
|
+
"items": {
|
|
27
|
+
"product": "Product",
|
|
28
|
+
"solutions": "Solutions",
|
|
29
|
+
"resources": "Resources",
|
|
30
|
+
"workspace": "Creative workspace",
|
|
31
|
+
"workspaceBoard": "Workspace board",
|
|
32
|
+
"designSystem": "Design system",
|
|
33
|
+
"playbook": "Playbook",
|
|
34
|
+
"creativePlaybook": "Creative playbook",
|
|
35
|
+
"contentHub": "Content hub",
|
|
36
|
+
"overview": "Overview",
|
|
37
|
+
"company": "Company",
|
|
38
|
+
"pricing": "Pricing",
|
|
39
|
+
"blog": "Blog",
|
|
40
|
+
"terms": "Terms",
|
|
41
|
+
"privacy": "Privacy",
|
|
42
|
+
"cookies": "Cookies"
|
|
43
|
+
},
|
|
44
|
+
"descriptions": {
|
|
45
|
+
"workspace": "Application shell for production workflows",
|
|
46
|
+
"designSystem": "Tokens, components, checkout, identity, video, and infinite-scroll examples",
|
|
47
|
+
"playbook": "Content detail shell for product education and SEO",
|
|
48
|
+
"privacy": "Static policy page"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"language": {
|
|
52
|
+
"current": "Current language: {language}",
|
|
53
|
+
"select": "Select language"
|
|
54
|
+
},
|
|
55
|
+
"theme": {
|
|
56
|
+
"current": "Current theme: {theme}",
|
|
57
|
+
"select": "Select theme",
|
|
58
|
+
"system": "System",
|
|
59
|
+
"light": "Light",
|
|
60
|
+
"dark": "Dark"
|
|
61
|
+
},
|
|
62
|
+
"consent": {
|
|
63
|
+
"region": "Analytics preferences",
|
|
64
|
+
"title": "Your analytics choice",
|
|
65
|
+
"message": "We use necessary storage for your choices and optional analytics to understand how the site is used. Analytics stays off unless you allow it.",
|
|
66
|
+
"policy": "Read the cookie policy",
|
|
67
|
+
"decline": "Decline analytics",
|
|
68
|
+
"allow": "Allow analytics",
|
|
69
|
+
"close": "Close analytics settings",
|
|
70
|
+
"settings": "Cookie settings",
|
|
71
|
+
"storageUnavailable": "This browser did not allow us to read your saved choice. Analytics remains off until you choose for this session.",
|
|
72
|
+
"storageCleanupFailed": "An invalid or expired saved choice could not be removed. Analytics remains off.",
|
|
73
|
+
"storageCleared": "Your previous analytics choice was removed. We will ask again on your next visit.",
|
|
74
|
+
"sessionOnly": "Your choice applies to this visit, but the browser did not allow us to save it."
|
|
75
|
+
},
|
|
76
|
+
"footer": {
|
|
77
|
+
"navigation": "Footer navigation",
|
|
78
|
+
"legal": "Legal navigation",
|
|
79
|
+
"copyright": "© {year} {site}"
|
|
80
|
+
},
|
|
81
|
+
"content": {
|
|
82
|
+
"related": "Related",
|
|
83
|
+
"relatedRegion": "Related content",
|
|
84
|
+
"updated": "Updated {date}"
|
|
85
|
+
},
|
|
86
|
+
"notifications": {
|
|
87
|
+
"region": "Notifications",
|
|
88
|
+
"dismiss": "Dismiss notification"
|
|
89
|
+
},
|
|
90
|
+
"legal": {
|
|
91
|
+
"cookies": {
|
|
92
|
+
"title": "Cookie and storage policy",
|
|
93
|
+
"description": "How this site stores preferences and uses optional analytics.",
|
|
94
|
+
"updated": "September 13, 2026",
|
|
95
|
+
"introTitle": "How storage works on this site",
|
|
96
|
+
"intro": "We use browser storage and cookies to remember choices you request. Optional analytics remains disabled until you allow it in the analytics settings.",
|
|
97
|
+
"inventoryTitle": "Storage inventory",
|
|
98
|
+
"inventoryDescription": "The table lists the storage configured by this site. Analytics entries are created only when analytics is configured and you allow it.",
|
|
99
|
+
"name": "Name",
|
|
100
|
+
"category": "Category",
|
|
101
|
+
"purpose": "Purpose",
|
|
102
|
+
"provider": "Provider",
|
|
103
|
+
"retention": "Retention",
|
|
104
|
+
"categories": {
|
|
105
|
+
"necessary": "Necessary",
|
|
106
|
+
"preferences": "Preferences",
|
|
107
|
+
"analytics": "Analytics"
|
|
108
|
+
},
|
|
109
|
+
"choicesTitle": "Your choices",
|
|
110
|
+
"choices": "You can allow or decline optional analytics when the banner appears. Use Cookie settings in the footer to review or withdraw that choice later. Theme and language storage records the presentation options you select.",
|
|
111
|
+
"changesTitle": "Changes to this policy",
|
|
112
|
+
"changes": "We will update this page when the storage categories, purposes, providers, or retention periods materially change. A material analytics-policy change requires a new choice.",
|
|
113
|
+
"contactTitle": "Questions",
|
|
114
|
+
"contact": "Use the contact details in the privacy policy if you have questions about this storage inventory.",
|
|
115
|
+
"storage": {
|
|
116
|
+
"consentPurpose": "Records whether you allowed or declined optional analytics.",
|
|
117
|
+
"consentRetention": "180 days from your latest choice.",
|
|
118
|
+
"themePurpose": "Remembers the system, light, or dark appearance you selected.",
|
|
119
|
+
"localePurpose": "Remembers the language you selected.",
|
|
120
|
+
"localeRetention": "Up to one year.",
|
|
121
|
+
"analyticsPurpose": "Distinguishes visits and measures aggregate site usage after analytics is allowed.",
|
|
122
|
+
"analyticsRetention": "Up to two years, subject to the configured Google Analytics retention settings.",
|
|
123
|
+
"persistent": "Until you clear site storage or change the preference.",
|
|
124
|
+
"firstParty": "This site",
|
|
125
|
+
"google": "Google Analytics"
|
|
126
|
+
}
|
|
127
|
+
}
|
|
4
128
|
}
|
|
5
129
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const site = useSiteShell();
|
|
3
|
+
const { t } = useI18n();
|
|
4
|
+
|
|
5
|
+
useSeo({
|
|
6
|
+
title: t("legal.cookies.title"),
|
|
7
|
+
description: t("legal.cookies.description"),
|
|
8
|
+
canonicalPath: site.value.consent.policyPath,
|
|
9
|
+
});
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<StaticPage
|
|
14
|
+
:title="t('legal.cookies.title')"
|
|
15
|
+
:description="t('legal.cookies.description')"
|
|
16
|
+
:updated-at="t('legal.cookies.updated')"
|
|
17
|
+
>
|
|
18
|
+
<section>
|
|
19
|
+
<h2>{{ t("legal.cookies.introTitle") }}</h2>
|
|
20
|
+
<p>{{ t("legal.cookies.intro") }}</p>
|
|
21
|
+
</section>
|
|
22
|
+
|
|
23
|
+
<section>
|
|
24
|
+
<h2>{{ t("legal.cookies.inventoryTitle") }}</h2>
|
|
25
|
+
<p>{{ t("legal.cookies.inventoryDescription") }}</p>
|
|
26
|
+
<div class="storage-inventory">
|
|
27
|
+
<table>
|
|
28
|
+
<thead>
|
|
29
|
+
<tr>
|
|
30
|
+
<th scope="col">{{ t("legal.cookies.name") }}</th>
|
|
31
|
+
<th scope="col">{{ t("legal.cookies.category") }}</th>
|
|
32
|
+
<th scope="col">{{ t("legal.cookies.purpose") }}</th>
|
|
33
|
+
<th scope="col">{{ t("legal.cookies.provider") }}</th>
|
|
34
|
+
<th scope="col">{{ t("legal.cookies.retention") }}</th>
|
|
35
|
+
</tr>
|
|
36
|
+
</thead>
|
|
37
|
+
<tbody>
|
|
38
|
+
<tr v-for="item in site.storageDisclosures" :key="item.name">
|
|
39
|
+
<th scope="row">
|
|
40
|
+
<code>{{ item.name }}</code>
|
|
41
|
+
</th>
|
|
42
|
+
<td>{{ t(`legal.cookies.categories.${item.category}`) }}</td>
|
|
43
|
+
<td>{{ t(item.purposeKey) }}</td>
|
|
44
|
+
<td>{{ t(item.providerKey) }}</td>
|
|
45
|
+
<td>{{ t(item.retentionKey) }}</td>
|
|
46
|
+
</tr>
|
|
47
|
+
</tbody>
|
|
48
|
+
</table>
|
|
49
|
+
</div>
|
|
50
|
+
</section>
|
|
51
|
+
|
|
52
|
+
<section>
|
|
53
|
+
<h2>{{ t("legal.cookies.choicesTitle") }}</h2>
|
|
54
|
+
<p>{{ t("legal.cookies.choices") }}</p>
|
|
55
|
+
<ConsentSettingsButton />
|
|
56
|
+
</section>
|
|
57
|
+
|
|
58
|
+
<section>
|
|
59
|
+
<h2>{{ t("legal.cookies.changesTitle") }}</h2>
|
|
60
|
+
<p>{{ t("legal.cookies.changes") }}</p>
|
|
61
|
+
</section>
|
|
62
|
+
|
|
63
|
+
<section>
|
|
64
|
+
<h2>{{ t("legal.cookies.contactTitle") }}</h2>
|
|
65
|
+
<p>{{ t("legal.cookies.contact") }}</p>
|
|
66
|
+
</section>
|
|
67
|
+
</StaticPage>
|
|
68
|
+
</template>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineNuxtConfig } from "nuxt/config";
|
|
2
2
|
|
|
3
3
|
const siteEnv = process.env.NUXT_PUBLIC_SITE_ENV || "development";
|
|
4
|
+
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://example.com";
|
|
4
5
|
const gtmId = process.env.NUXT_PUBLIC_GTM_ID || "";
|
|
5
6
|
|
|
6
7
|
export default defineNuxtConfig({
|
|
@@ -8,6 +9,21 @@ export default defineNuxtConfig({
|
|
|
8
9
|
modules: ["@nuxt/eslint", "@stackonward/google-tag-manager-nuxt"],
|
|
9
10
|
compatibilityDate: "2025-07-15",
|
|
10
11
|
css: ["~/assets/css/site-shell.css"],
|
|
12
|
+
i18n: {
|
|
13
|
+
baseUrl: siteUrl,
|
|
14
|
+
defaultLocale: "en",
|
|
15
|
+
strategy: "prefix_except_default",
|
|
16
|
+
restructureDir: "app/i18n",
|
|
17
|
+
langDir: "locales",
|
|
18
|
+
locales: [{ code: "en", language: "en-US", name: "English", file: "en.json" }],
|
|
19
|
+
detectBrowserLanguage: {
|
|
20
|
+
useCookie: true,
|
|
21
|
+
cookieKey: "{{projectName}}_locale",
|
|
22
|
+
redirectOn: "root",
|
|
23
|
+
fallbackLocale: "en",
|
|
24
|
+
alwaysRedirect: false,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
11
27
|
components: [
|
|
12
28
|
{
|
|
13
29
|
path: "~/components",
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@stackonward/api-client": "^0.0.2",
|
|
32
|
-
"@stackonward/google-tag-manager-nuxt": "^0.2.
|
|
32
|
+
"@stackonward/google-tag-manager-nuxt": "^0.2.3",
|
|
33
33
|
"@stackonward/nuxt-layer-base": "^0.0.3",
|
|
34
|
-
"@stackonward/nuxt-site-shell": "^0.
|
|
34
|
+
"@stackonward/nuxt-site-shell": "^0.2.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@commitlint/cli": "^21.2.2",
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { computed, ref } from "vue";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
ConsentLifecycleError,
|
|
5
|
+
createSiteConsentController,
|
|
6
|
+
} from "../../app/composables/useConsent";
|
|
7
|
+
import type { ConsentState, SiteShellConsentConfig } from "../../app/types/site-shell";
|
|
8
|
+
|
|
9
|
+
class ControlledStorage implements Storage {
|
|
10
|
+
readonly values = new Map<string, string>();
|
|
11
|
+
failRead = false;
|
|
12
|
+
failWrite = false;
|
|
13
|
+
failClear = false;
|
|
14
|
+
|
|
15
|
+
get length(): number {
|
|
16
|
+
return this.values.size;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
clear(): void {
|
|
20
|
+
this.values.clear();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
getItem(key: string): string | null {
|
|
24
|
+
if (this.failRead) throw new Error("read failed");
|
|
25
|
+
return this.values.get(key) ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
key(index: number): string | null {
|
|
29
|
+
return [...this.values.keys()][index] ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
removeItem(key: string): void {
|
|
33
|
+
if (this.failClear) throw new Error("clear failed");
|
|
34
|
+
this.values.delete(key);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
setItem(key: string, value: string): void {
|
|
38
|
+
if (this.failWrite) throw new Error("write failed");
|
|
39
|
+
this.values.set(key, value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const storageKey = "sample-analytics-consent-v1";
|
|
44
|
+
const config: SiteShellConsentConfig = {
|
|
45
|
+
enabled: true,
|
|
46
|
+
storageKey,
|
|
47
|
+
policyRevision: "1",
|
|
48
|
+
retentionDays: 180,
|
|
49
|
+
policyPath: "/legal/cookies",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function record(value: "accepted" | "rejected", expiresAt: number, policyRevision = "1"): string {
|
|
53
|
+
return JSON.stringify({
|
|
54
|
+
version: 1,
|
|
55
|
+
policyRevision,
|
|
56
|
+
value,
|
|
57
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("site consent controller", () => {
|
|
62
|
+
let storage: ControlledStorage;
|
|
63
|
+
let windowListeners: Map<string, Set<EventListener>>;
|
|
64
|
+
let documentListeners: Map<string, Set<EventListener>>;
|
|
65
|
+
let visibilityState: DocumentVisibilityState;
|
|
66
|
+
let analyticsState: "unknown" | "granted" | "denied";
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
vi.useFakeTimers();
|
|
70
|
+
vi.setSystemTime(new Date("2026-09-13T00:00:00.000Z"));
|
|
71
|
+
storage = new ControlledStorage();
|
|
72
|
+
windowListeners = new Map();
|
|
73
|
+
documentListeners = new Map();
|
|
74
|
+
visibilityState = "visible";
|
|
75
|
+
vi.stubGlobal("window", {
|
|
76
|
+
localStorage: storage,
|
|
77
|
+
addEventListener(type: string, listener: EventListener) {
|
|
78
|
+
const listeners = windowListeners.get(type) ?? new Set<EventListener>();
|
|
79
|
+
listeners.add(listener);
|
|
80
|
+
windowListeners.set(type, listeners);
|
|
81
|
+
},
|
|
82
|
+
removeEventListener(type: string, listener: EventListener) {
|
|
83
|
+
windowListeners.get(type)?.delete(listener);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
vi.stubGlobal("document", {
|
|
87
|
+
get visibilityState() {
|
|
88
|
+
return visibilityState;
|
|
89
|
+
},
|
|
90
|
+
addEventListener(type: string, listener: EventListener) {
|
|
91
|
+
const listeners = documentListeners.get(type) ?? new Set<EventListener>();
|
|
92
|
+
listeners.add(listener);
|
|
93
|
+
documentListeners.set(type, listeners);
|
|
94
|
+
},
|
|
95
|
+
removeEventListener(type: string, listener: EventListener) {
|
|
96
|
+
documentListeners.get(type)?.delete(listener);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
analyticsState = "unknown";
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
vi.useRealTimers();
|
|
104
|
+
vi.unstubAllGlobals();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
function createController() {
|
|
108
|
+
const state = ref<ConsentState>({
|
|
109
|
+
decision: null,
|
|
110
|
+
ready: false,
|
|
111
|
+
settingsOpen: false,
|
|
112
|
+
persistence: "none",
|
|
113
|
+
error: null,
|
|
114
|
+
});
|
|
115
|
+
const controller = createSiteConsentController({
|
|
116
|
+
state,
|
|
117
|
+
config: computed(() => config),
|
|
118
|
+
analytics: {
|
|
119
|
+
available: () => true,
|
|
120
|
+
grant: () => {
|
|
121
|
+
analyticsState = "granted";
|
|
122
|
+
},
|
|
123
|
+
deny: () => {
|
|
124
|
+
analyticsState = "denied";
|
|
125
|
+
},
|
|
126
|
+
reset: () => {
|
|
127
|
+
analyticsState = "unknown";
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
return { controller, state };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function dispatch(type: "storage" | "visibilitychange", event: Event): void {
|
|
135
|
+
const listeners = type === "storage" ? windowListeners.get(type) : documentListeners.get(type);
|
|
136
|
+
for (const listener of listeners ?? []) listener(event);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
it("restores, synchronizes, expires, and replaces the single deadline", () => {
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
storage.setItem(storageKey, record("accepted", now + 2_000));
|
|
142
|
+
const { controller, state } = createController();
|
|
143
|
+
|
|
144
|
+
expect(controller.initialize()).toEqual({ status: "restored", value: "accepted" });
|
|
145
|
+
expect(controller.initialize()).toEqual({ status: "restored", value: "accepted" });
|
|
146
|
+
expect(analyticsState).toBe("granted");
|
|
147
|
+
expect(state.value.decision).toBe("accepted");
|
|
148
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
149
|
+
expect(windowListeners.get("storage")?.size).toBe(1);
|
|
150
|
+
|
|
151
|
+
storage.setItem(storageKey, record("rejected", now + 4_000));
|
|
152
|
+
dispatch("storage", { key: storageKey } as StorageEvent);
|
|
153
|
+
expect(analyticsState).toBe("denied");
|
|
154
|
+
expect(state.value.decision).toBe("rejected");
|
|
155
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
156
|
+
|
|
157
|
+
vi.advanceTimersByTime(4_001);
|
|
158
|
+
expect(analyticsState).toBe("unknown");
|
|
159
|
+
expect(state.value).toMatchObject({
|
|
160
|
+
decision: null,
|
|
161
|
+
ready: true,
|
|
162
|
+
persistence: "cleared",
|
|
163
|
+
error: null,
|
|
164
|
+
});
|
|
165
|
+
expect(storage.getItem(storageKey)).toBeNull();
|
|
166
|
+
expect(controller.visible.value).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("revalidates on visibility and bounds long-lived deadlines", () => {
|
|
170
|
+
const year = 366 * 24 * 60 * 60 * 1_000;
|
|
171
|
+
storage.setItem(storageKey, record("accepted", Date.now() + year));
|
|
172
|
+
const { controller, state } = createController();
|
|
173
|
+
controller.initialize();
|
|
174
|
+
|
|
175
|
+
vi.advanceTimersByTime(2_147_483_647);
|
|
176
|
+
expect(state.value.decision).toBe("accepted");
|
|
177
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
178
|
+
|
|
179
|
+
storage.setItem(storageKey, record("rejected", Date.now() + 10_000));
|
|
180
|
+
visibilityState = "hidden";
|
|
181
|
+
dispatch("visibilitychange", {} as Event);
|
|
182
|
+
expect(state.value.decision).toBe("accepted");
|
|
183
|
+
visibilityState = "visible";
|
|
184
|
+
dispatch("visibilitychange", {} as Event);
|
|
185
|
+
expect(state.value.decision).toBe("rejected");
|
|
186
|
+
expect(analyticsState).toBe("denied");
|
|
187
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("keeps invalid and revised records denied with observable cleanup outcomes", () => {
|
|
191
|
+
storage.setItem(storageKey, "not-json");
|
|
192
|
+
const first = createController();
|
|
193
|
+
expect(first.controller.initialize()).toEqual({ status: "undecided", cleanup: "cleared" });
|
|
194
|
+
expect(first.state.value.persistence).toBe("cleared");
|
|
195
|
+
expect(analyticsState).toBe("unknown");
|
|
196
|
+
first.controller.dispose();
|
|
197
|
+
|
|
198
|
+
storage.setItem(storageKey, record("accepted", Date.now() + 10_000, "outdated"));
|
|
199
|
+
storage.failClear = true;
|
|
200
|
+
const second = createController();
|
|
201
|
+
expect(second.controller.initialize()).toEqual({
|
|
202
|
+
status: "undecided",
|
|
203
|
+
cleanup: "failed",
|
|
204
|
+
error: "clear-failed",
|
|
205
|
+
});
|
|
206
|
+
expect(second.state.value).toMatchObject({
|
|
207
|
+
decision: null,
|
|
208
|
+
persistence: "cleanup-failed",
|
|
209
|
+
error: "clear-failed",
|
|
210
|
+
});
|
|
211
|
+
expect(analyticsState).toBe("unknown");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("reports read failure and keeps the choice UI available", () => {
|
|
215
|
+
storage.failRead = true;
|
|
216
|
+
const { controller, state } = createController();
|
|
217
|
+
|
|
218
|
+
expect(controller.initialize()).toEqual({
|
|
219
|
+
status: "storage-unavailable",
|
|
220
|
+
error: "read-failed",
|
|
221
|
+
});
|
|
222
|
+
expect(state.value).toMatchObject({ decision: null, ready: true, error: "read-failed" });
|
|
223
|
+
expect(controller.visible.value).toBe(true);
|
|
224
|
+
expect(analyticsState).toBe("unknown");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("preserves explicit session behavior across write and withdrawal failures", () => {
|
|
228
|
+
const accepted = createController();
|
|
229
|
+
accepted.controller.initialize();
|
|
230
|
+
storage.failWrite = true;
|
|
231
|
+
expect(accepted.controller.accept()).toEqual({
|
|
232
|
+
status: "session-only",
|
|
233
|
+
value: "accepted",
|
|
234
|
+
error: "write-failed",
|
|
235
|
+
});
|
|
236
|
+
expect(analyticsState).toBe("granted");
|
|
237
|
+
expect(accepted.state.value.settingsOpen).toBe(true);
|
|
238
|
+
accepted.controller.closeSettings();
|
|
239
|
+
expect(accepted.controller.visible.value).toBe(false);
|
|
240
|
+
accepted.controller.dispose();
|
|
241
|
+
|
|
242
|
+
storage.values.set(storageKey, record("accepted", Date.now() + 10_000));
|
|
243
|
+
const rejected = createController();
|
|
244
|
+
storage.failWrite = false;
|
|
245
|
+
rejected.controller.initialize();
|
|
246
|
+
storage.failWrite = true;
|
|
247
|
+
expect(rejected.controller.reject()).toEqual({
|
|
248
|
+
status: "cleared",
|
|
249
|
+
value: "rejected",
|
|
250
|
+
error: "write-failed",
|
|
251
|
+
});
|
|
252
|
+
expect(storage.values.has(storageKey)).toBe(false);
|
|
253
|
+
expect(analyticsState).toBe("denied");
|
|
254
|
+
rejected.controller.dispose();
|
|
255
|
+
|
|
256
|
+
storage.values.set(storageKey, record("accepted", Date.now() + 10_000));
|
|
257
|
+
storage.failWrite = false;
|
|
258
|
+
const uncleared = createController();
|
|
259
|
+
uncleared.controller.initialize();
|
|
260
|
+
storage.failWrite = true;
|
|
261
|
+
storage.failClear = true;
|
|
262
|
+
expect(uncleared.controller.reject()).toEqual({
|
|
263
|
+
status: "session-only",
|
|
264
|
+
value: "rejected",
|
|
265
|
+
error: "write-and-clear-failed",
|
|
266
|
+
});
|
|
267
|
+
expect(uncleared.state.value.error).toBe("write-and-clear-failed");
|
|
268
|
+
expect(analyticsState).toBe("denied");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("owns settings and disposes all browser effects", () => {
|
|
272
|
+
const { controller, state } = createController();
|
|
273
|
+
expect(() => controller.openSettings()).toThrow(ConsentLifecycleError);
|
|
274
|
+
controller.initialize();
|
|
275
|
+
expect(() => controller.closeSettings()).toThrow(ConsentLifecycleError);
|
|
276
|
+
controller.reject();
|
|
277
|
+
controller.openSettings();
|
|
278
|
+
expect(state.value.settingsOpen).toBe(true);
|
|
279
|
+
controller.closeSettings();
|
|
280
|
+
expect(state.value.settingsOpen).toBe(false);
|
|
281
|
+
|
|
282
|
+
controller.dispose();
|
|
283
|
+
controller.dispose();
|
|
284
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
285
|
+
expect(windowListeners.get("storage")?.size).toBe(0);
|
|
286
|
+
expect(documentListeners.get("visibilitychange")?.size).toBe(0);
|
|
287
|
+
expect(() => controller.accept()).toThrow(ConsentLifecycleError);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
1
2
|
import { defineConfig } from "vitest/config";
|
|
2
3
|
|
|
3
4
|
export default defineConfig({
|
|
5
|
+
resolve: {
|
|
6
|
+
alias: {
|
|
7
|
+
"~": fileURLToPath(new URL("./app", import.meta.url)),
|
|
8
|
+
},
|
|
9
|
+
},
|
|
4
10
|
test: {
|
|
5
11
|
include: ["test/**/*.test.ts"],
|
|
6
12
|
testTimeout: 120_000,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stackonward/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "StackOnward CLI for creating projects and installing reusable components and snippets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"ora": "^8.0.0",
|
|
37
37
|
"prompts": "^2.4.2",
|
|
38
38
|
"zod": "4",
|
|
39
|
-
"@stackonward/
|
|
40
|
-
"@stackonward/
|
|
39
|
+
"@stackonward/google-tag-manager": "0.1.1",
|
|
40
|
+
"@stackonward/api-client": "0.0.2"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/fs-extra": "^11.0.4",
|