@stackonward/cli 1.2.1 → 1.3.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.
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 +7 -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,17 +1,57 @@
1
1
  <script setup lang="ts">
2
2
  const site = useSiteShell();
3
3
  const consent = useConsent();
4
+ const { t } = useI18n();
5
+
6
+ const persistenceMessage = computed(() => {
7
+ if (consent.state.value.error === "read-failed") {
8
+ return t("consent.storageUnavailable");
9
+ }
10
+ if (consent.state.value.error === "clear-failed") {
11
+ return t("consent.storageCleanupFailed");
12
+ }
13
+ if (consent.state.value.persistence === "cleared") {
14
+ return t("consent.storageCleared");
15
+ }
16
+ if (consent.state.value.persistence === "session-only") {
17
+ return t("consent.sessionOnly");
18
+ }
19
+ return "";
20
+ });
4
21
  </script>
5
22
 
6
23
  <template>
7
- <section v-if="consent.visible.value" class="site-consent" role="region" aria-label="Consent">
8
- <p class="site-consent__message">{{ site.consent.message }}</p>
24
+ <section
25
+ v-if="consent.visible.value"
26
+ class="site-consent"
27
+ role="region"
28
+ :aria-label="t('consent.region')"
29
+ >
30
+ <div class="site-consent__header">
31
+ <h2>{{ t("consent.title") }}</h2>
32
+ <button
33
+ v-if="consent.state.value.decision"
34
+ type="button"
35
+ class="site-consent__close"
36
+ :aria-label="t('consent.close')"
37
+ @click="consent.closeSettings"
38
+ >
39
+ <UIcon name="i-lucide-x" aria-hidden="true" />
40
+ </button>
41
+ </div>
42
+ <p class="site-consent__message">{{ t("consent.message") }}</p>
43
+ <NuxtLink class="site-consent__policy" :to="site.consent.policyPath">
44
+ {{ t("consent.policy") }}
45
+ </NuxtLink>
46
+ <p v-if="persistenceMessage" class="site-consent__status" role="status">
47
+ {{ persistenceMessage }}
48
+ </p>
9
49
  <div class="site-consent__actions">
10
50
  <button type="button" class="base-button base-button--secondary" @click="consent.reject">
11
- {{ site.consent.rejectLabel }}
51
+ {{ t("consent.decline") }}
12
52
  </button>
13
53
  <button type="button" class="base-button base-button--primary" @click="consent.accept">
14
- {{ site.consent.acceptLabel }}
54
+ {{ t("consent.allow") }}
15
55
  </button>
16
56
  </div>
17
57
  </section>
@@ -0,0 +1,57 @@
1
+ <script setup lang="ts">
2
+ const emit = defineEmits<{ changed: [] }>();
3
+ const { locale, locales, setLocale, t } = useI18n();
4
+ const dropdown = ref<HTMLDetailsElement | null>(null);
5
+
6
+ const options = computed(() =>
7
+ locales.value.map((item) => ({
8
+ code: item.code,
9
+ name: item.name || item.code.toUpperCase(),
10
+ })),
11
+ );
12
+ const current = computed(
13
+ () => options.value.find((option) => option.code === locale.value) || options.value[0],
14
+ );
15
+
16
+ async function selectLocale(code: typeof locale.value): Promise<void> {
17
+ dropdown.value?.removeAttribute("open");
18
+ if (code !== locale.value) await setLocale(code);
19
+ emit("changed");
20
+ }
21
+
22
+ function handleKeydown(event: KeyboardEvent): void {
23
+ if (event.key !== "Escape" || !dropdown.value?.open) return;
24
+ event.preventDefault();
25
+ dropdown.value.open = false;
26
+ dropdown.value.querySelector<HTMLElement>("summary")?.focus();
27
+ }
28
+
29
+ onClickOutside(dropdown, () => dropdown.value?.removeAttribute("open"));
30
+ </script>
31
+
32
+ <template>
33
+ <details
34
+ v-if="options.length > 1"
35
+ ref="dropdown"
36
+ class="site-preference"
37
+ @keydown="handleKeydown"
38
+ >
39
+ <summary :aria-label="t('language.current', { language: current?.name || locale })">
40
+ <UIcon name="i-lucide-languages" aria-hidden="true" />
41
+ <span>{{ current?.code.toUpperCase() || locale.toUpperCase() }}</span>
42
+ <UIcon class="site-preference__chevron" name="i-lucide-chevron-down" aria-hidden="true" />
43
+ </summary>
44
+ <div class="site-preference__menu" :aria-label="t('language.select')">
45
+ <button
46
+ v-for="option in options"
47
+ :key="option.code"
48
+ type="button"
49
+ :aria-pressed="option.code === locale"
50
+ @click="selectLocale(option.code)"
51
+ >
52
+ <span>{{ option.name }}</span>
53
+ <UIcon v-if="option.code === locale" name="i-lucide-check" aria-hidden="true" />
54
+ </button>
55
+ </div>
56
+ </details>
57
+ </template>
@@ -1,15 +1,16 @@
1
1
  <script setup lang="ts">
2
2
  const site = useSiteShell();
3
3
  const { footerGroups, legalLinks, socialLinks } = useNavigation();
4
+ const { t } = useI18n();
4
5
  const year = new Date().getFullYear();
5
6
  </script>
6
7
 
7
8
  <template>
8
9
  <footer class="marketing-footer">
9
10
  <div class="marketing-footer__inner">
10
- <section class="marketing-footer__brand" aria-label="Site">
11
+ <section class="marketing-footer__brand" :aria-label="t('site.region')">
11
12
  <SiteBrand />
12
- <p>{{ site.description }}</p>
13
+ <p>{{ t(site.descriptionKey) }}</p>
13
14
  <div v-if="socialLinks.length" class="marketing-footer__social">
14
15
  <a
15
16
  v-for="link in socialLinks"
@@ -23,7 +24,7 @@ const year = new Date().getFullYear();
23
24
  </div>
24
25
  </section>
25
26
 
26
- <nav class="marketing-footer__nav" aria-label="Footer">
27
+ <nav class="marketing-footer__nav" :aria-label="t('footer.navigation')">
27
28
  <section v-for="group in footerGroups" :key="group.title" class="marketing-footer__group">
28
29
  <h2>{{ group.title }}</h2>
29
30
  <ul>
@@ -31,9 +32,9 @@ const year = new Date().getFullYear();
31
32
  <a v-if="link.external" :href="link.to" target="_blank" rel="noopener noreferrer">
32
33
  {{ link.label }}
33
34
  </a>
34
- <NuxtLink v-else :to="link.to || site.homePath">
35
+ <NuxtLinkLocale v-else :to="link.to || site.homePath">
35
36
  {{ link.label }}
36
- </NuxtLink>
37
+ </NuxtLinkLocale>
37
38
  </li>
38
39
  </ul>
39
40
  </section>
@@ -41,11 +42,12 @@ const year = new Date().getFullYear();
41
42
  </div>
42
43
 
43
44
  <div class="marketing-footer__legal">
44
- <span>© {{ year }} {{ site.name }}</span>
45
- <nav aria-label="Legal">
46
- <NuxtLink v-for="link in legalLinks" :key="link.label" :to="link.to">
45
+ <span>{{ t("footer.copyright", { year, site: site.name }) }}</span>
46
+ <nav :aria-label="t('footer.legal')">
47
+ <NuxtLinkLocale v-for="link in legalLinks" :key="link.label" :to="link.to">
47
48
  {{ link.label }}
48
- </NuxtLink>
49
+ </NuxtLinkLocale>
50
+ <ConsentSettingsButton />
49
51
  </nav>
50
52
  </div>
51
53
  </footer>
@@ -2,7 +2,8 @@
2
2
  import type { MarketingNavigationItem } from "~/types/site-shell";
3
3
 
4
4
  const site = useSiteShell();
5
- const { marketingNav } = useNavigation();
5
+ const { marketingNav, primaryActionLabel, secondaryActionLabel } = useNavigation();
6
+ const { t } = useI18n();
6
7
  const menuOpen = ref(false);
7
8
 
8
9
  function closeMenu(): void {
@@ -19,12 +20,12 @@ function hasChildren(item: MarketingNavigationItem): boolean {
19
20
  <div class="marketing-header__inner">
20
21
  <SiteBrand />
21
22
 
22
- <nav class="marketing-header__nav" aria-label="Primary">
23
+ <nav class="marketing-header__nav" :aria-label="t('navigation.primary')">
23
24
  <template v-for="item in marketingNav" :key="item.label">
24
25
  <details v-if="hasChildren(item)" class="marketing-nav-menu">
25
26
  <summary>{{ item.label }}</summary>
26
27
  <div class="marketing-nav-menu__panel">
27
- <NuxtLink
28
+ <NuxtLinkLocale
28
29
  v-for="child in item.children"
29
30
  :key="child.label"
30
31
  :to="child.to || site.homePath"
@@ -32,7 +33,7 @@ function hasChildren(item: MarketingNavigationItem): boolean {
32
33
  >
33
34
  <span>{{ child.label }}</span>
34
35
  <small v-if="child.description">{{ child.description }}</small>
35
- </NuxtLink>
36
+ </NuxtLinkLocale>
36
37
  </div>
37
38
  </details>
38
39
  <a
@@ -44,31 +45,38 @@ function hasChildren(item: MarketingNavigationItem): boolean {
44
45
  >
45
46
  {{ item.label }}
46
47
  </a>
47
- <NuxtLink v-else class="marketing-nav-link" :to="item.to || site.homePath">
48
+ <NuxtLinkLocale v-else class="marketing-nav-link" :to="item.to || site.homePath">
48
49
  {{ item.label }}
49
- </NuxtLink>
50
+ </NuxtLinkLocale>
50
51
  </template>
51
52
  </nav>
52
53
 
53
54
  <div class="marketing-header__actions">
55
+ <div class="marketing-header__preferences">
56
+ <ThemeSwitcher />
57
+ <LocaleSwitcher />
58
+ </div>
54
59
  <slot name="actions">
55
- <NuxtLink :to="site.secondaryActionPath" class="base-button base-button--secondary">
56
- {{ site.secondaryActionLabel }}
57
- </NuxtLink>
58
- <NuxtLink :to="site.appPath" class="base-button base-button--primary">
59
- {{ site.primaryActionLabel }}
60
+ <NuxtLinkLocale :to="site.secondaryActionPath" class="base-button base-button--secondary">
61
+ {{ secondaryActionLabel }}
62
+ </NuxtLinkLocale>
63
+ <NuxtLinkLocale :to="site.appPath" class="base-button base-button--primary">
64
+ {{ primaryActionLabel }}
60
65
  <UIcon name="i-lucide-arrow-right" />
61
- </NuxtLink>
66
+ </NuxtLinkLocale>
62
67
  </slot>
63
68
  <button
64
69
  type="button"
65
70
  class="icon-button marketing-header__menu-button"
66
71
  :aria-expanded="menuOpen"
67
72
  aria-controls="mobile-nav"
68
- aria-label="Toggle navigation"
73
+ :aria-label="t('navigation.toggle')"
69
74
  @click="menuOpen = !menuOpen"
70
75
  >
71
- <UIcon :name="menuOpen ? 'i-lucide-x' : 'i-lucide-menu'" class="marketing-header__menu-icon" />
76
+ <UIcon
77
+ :name="menuOpen ? 'i-lucide-x' : 'i-lucide-menu'"
78
+ class="marketing-header__menu-icon"
79
+ />
72
80
  </button>
73
81
  </div>
74
82
  </div>
@@ -77,20 +85,20 @@ function hasChildren(item: MarketingNavigationItem): boolean {
77
85
  id="mobile-nav"
78
86
  class="mobile-nav"
79
87
  :class="{ 'mobile-nav--open': menuOpen }"
80
- aria-label="Mobile primary"
88
+ :aria-label="t('navigation.mobilePrimary')"
81
89
  >
82
90
  <template v-for="item in marketingNav" :key="item.label">
83
- <NuxtLink
91
+ <NuxtLinkLocale
84
92
  v-if="!hasChildren(item)"
85
93
  :to="item.to || site.homePath"
86
94
  class="mobile-nav__link"
87
95
  @click="closeMenu"
88
96
  >
89
97
  {{ item.label }}
90
- </NuxtLink>
98
+ </NuxtLinkLocale>
91
99
  <details v-else class="mobile-nav__group">
92
100
  <summary>{{ item.label }}</summary>
93
- <NuxtLink
101
+ <NuxtLinkLocale
94
102
  v-for="child in item.children"
95
103
  :key="child.label"
96
104
  :to="child.to || site.homePath"
@@ -98,9 +106,13 @@ function hasChildren(item: MarketingNavigationItem): boolean {
98
106
  @click="closeMenu"
99
107
  >
100
108
  {{ child.label }}
101
- </NuxtLink>
109
+ </NuxtLinkLocale>
102
110
  </details>
103
111
  </template>
112
+ <div class="mobile-nav__preferences">
113
+ <ThemeSwitcher @changed="closeMenu" />
114
+ <LocaleSwitcher @changed="closeMenu" />
115
+ </div>
104
116
  </nav>
105
117
  </header>
106
118
  </template>
@@ -7,9 +7,6 @@ const LazyMarketingFooter = defineLazyHydrationComponent(
7
7
  "visible",
8
8
  () => import("./MarketingFooter.vue"),
9
9
  );
10
-
11
- const consent = useConsent();
12
- const backToTopOffset = computed(() => (consent.visible.value ? 104 : 24));
13
10
  </script>
14
11
 
15
12
  <template>
@@ -18,7 +15,7 @@ const backToTopOffset = computed(() => (consent.visible.value ? 104 : 24));
18
15
  <LazyMarketingHeader :hydrate-on-idle="800" />
19
16
  </slot>
20
17
 
21
- <main class="marketing-shell__main">
18
+ <main id="site-content" class="marketing-shell__main">
22
19
  <slot />
23
20
  </main>
24
21
 
@@ -26,10 +23,11 @@ const backToTopOffset = computed(() => (consent.visible.value ? 104 : 24));
26
23
  <LazyMarketingFooter hydrate-on-visible />
27
24
  </slot>
28
25
 
29
- <BackToTop :bottom-offset="backToTopOffset" />
30
-
31
- <slot name="consent">
32
- <CookieConsent />
33
- </slot>
26
+ <div class="site-floating-stack">
27
+ <BackToTop />
28
+ <slot name="consent">
29
+ <CookieConsent />
30
+ </slot>
31
+ </div>
34
32
  </div>
35
33
  </template>
@@ -1,10 +1,11 @@
1
1
  <script setup lang="ts">
2
2
  const site = useSiteShell();
3
+ const { t } = useI18n();
3
4
  </script>
4
5
 
5
6
  <template>
6
- <NuxtLink :to="site.homePath" class="site-brand" aria-label="Home">
7
+ <NuxtLinkLocale :to="site.homePath" class="site-brand" :aria-label="t('site.home')">
7
8
  <span class="site-brand__mark" aria-hidden="true">{{ site.logoText.slice(0, 1) }}</span>
8
9
  <span class="site-brand__text">{{ site.logoText }}</span>
9
- </NuxtLink>
10
+ </NuxtLinkLocale>
10
11
  </template>
@@ -4,6 +4,7 @@ defineProps<{
4
4
  description?: string;
5
5
  updatedAt?: string;
6
6
  }>();
7
+ const { t } = useI18n();
7
8
  </script>
8
9
 
9
10
  <template>
@@ -11,7 +12,7 @@ defineProps<{
11
12
  <header class="page-heading">
12
13
  <h1>{{ title }}</h1>
13
14
  <p v-if="description">{{ description }}</p>
14
- <span v-if="updatedAt">Updated {{ updatedAt }}</span>
15
+ <span v-if="updatedAt">{{ t("content.updated", { date: updatedAt }) }}</span>
15
16
  </header>
16
17
 
17
18
  <div class="static-page__body">
@@ -0,0 +1,63 @@
1
+ <script setup lang="ts">
2
+ type ThemePreference = "system" | "light" | "dark";
3
+
4
+ const emit = defineEmits<{ changed: [] }>();
5
+ const colorMode = useColorMode();
6
+ const { t } = useI18n();
7
+ const dropdown = ref<HTMLDetailsElement | null>(null);
8
+ const options = [
9
+ { value: "system", labelKey: "theme.system", icon: "i-lucide-monitor" },
10
+ { value: "light", labelKey: "theme.light", icon: "i-lucide-sun" },
11
+ { value: "dark", labelKey: "theme.dark", icon: "i-lucide-moon" },
12
+ ] as const satisfies readonly { value: ThemePreference; labelKey: string; icon: string }[];
13
+ const preference = computed<ThemePreference>(() => {
14
+ const value = colorMode.preference;
15
+ return value === "light" || value === "dark" ? value : "system";
16
+ });
17
+ const current = computed(
18
+ () => options.find((option) => option.value === preference.value) ?? options[0],
19
+ );
20
+
21
+ function selectTheme(value: ThemePreference): void {
22
+ colorMode.preference = value;
23
+ dropdown.value?.removeAttribute("open");
24
+ emit("changed");
25
+ }
26
+
27
+ function handleKeydown(event: KeyboardEvent): void {
28
+ if (event.key !== "Escape" || !dropdown.value?.open) return;
29
+ event.preventDefault();
30
+ dropdown.value.open = false;
31
+ dropdown.value.querySelector<HTMLElement>("summary")?.focus();
32
+ }
33
+
34
+ onClickOutside(dropdown, () => dropdown.value?.removeAttribute("open"));
35
+ </script>
36
+
37
+ <template>
38
+ <details ref="dropdown" class="site-preference" @keydown="handleKeydown">
39
+ <summary :aria-label="t('theme.current', { theme: t(current.labelKey) })">
40
+ <UIcon :name="current.icon" aria-hidden="true" />
41
+ <span class="site-preference__current">{{ t(current.labelKey) }}</span>
42
+ <UIcon class="site-preference__chevron" name="i-lucide-chevron-down" aria-hidden="true" />
43
+ </summary>
44
+ <div class="site-preference__menu" :aria-label="t('theme.select')">
45
+ <button
46
+ v-for="option in options"
47
+ :key="option.value"
48
+ type="button"
49
+ :aria-pressed="option.value === preference"
50
+ @click="selectTheme(option.value)"
51
+ >
52
+ <UIcon :name="option.icon" aria-hidden="true" />
53
+ <span>{{ t(option.labelKey) }}</span>
54
+ <UIcon
55
+ v-if="option.value === preference"
56
+ class="site-preference__check"
57
+ name="i-lucide-check"
58
+ aria-hidden="true"
59
+ />
60
+ </button>
61
+ </div>
62
+ </details>
63
+ </template>
@@ -1,9 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  const { toasts, dismiss } = useToasts();
3
+ const { t } = useI18n();
3
4
  </script>
4
5
 
5
6
  <template>
6
- <div class="toast-viewport" role="region" aria-label="Notifications">
7
+ <div class="toast-viewport" role="region" :aria-label="t('notifications.region')">
7
8
  <TransitionGroup name="site-toast" tag="div" class="toast-viewport__stack">
8
9
  <section
9
10
  v-for="toast in toasts"
@@ -16,7 +17,7 @@ const { toasts, dismiss } = useToasts();
16
17
  <h2>{{ toast.title }}</h2>
17
18
  <p v-if="toast.description">{{ toast.description }}</p>
18
19
  </div>
19
- <button type="button" aria-label="Dismiss notification" @click="dismiss(toast.id)">
20
+ <button type="button" :aria-label="t('notifications.dismiss')" @click="dismiss(toast.id)">
20
21
  ×
21
22
  </button>
22
23
  </section>