@codecavepro/brand 3.2.6 → 3.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.
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { formattedDate } from "../helpers/date-formatter.ts";
3
+ import { formatMessage, useBrandMessages } from "../lib/i18n/messages.ts";
3
4
 
4
5
  /* The fields this component actually reads, instead of the generated Strapi
5
6
  * `Article`. TypeScript is structural, so a real Article still satisfies this
@@ -28,6 +29,7 @@ const props = defineProps<{
28
29
  }>()
29
30
 
30
31
  const imageUrl = (url: string) => props.resolveImage?.(url) ?? url
32
+ const messages = useBrandMessages()
31
33
  </script>
32
34
 
33
35
  <template>
@@ -35,13 +37,11 @@ const imageUrl = (url: string) => props.resolveImage?.(url) ?? url
35
37
  rounded-[2.25rem] bg-surface-secondary hover:bg-surface-tertiary transition-colors cursor-pointer border-surface-tertiary border
36
38
  ${className ?? ''}`">
37
39
  <div class="flex flex-col sm:flex-row gap-5 sm:gap-8 h-fit">
38
- <div class="w-full h-full p-1">
39
- <img loading="lazy" class="sm:w-[132px] sm:h-[132px] rounded-xl object-cover"
40
+ <img loading="lazy" class="sm:w-33 sm:h-33 rounded-xl object-cover"
40
41
  :src="imageUrl(article.cover.url)"
41
42
  :alt="article.title"
42
43
  :width="100"
43
44
  :height="100"/>
44
- </div>
45
45
  <div class="space-y-2 sm:space-y-3">
46
46
  <time class="text-body-secondary text-xs sm:text-sm" :datetime="article.date?.toString()">
47
47
  {{ formattedDate(article.locale, article.date) }}
@@ -56,7 +56,7 @@ const imageUrl = (url: string) => props.resolveImage?.(url) ?? url
56
56
  {{ article.excerpt }}
57
57
  </p>
58
58
  <p class="text-xs sm:text-sm text-body-secondary mt-6 sm:mt-7">
59
- Reading time: {{ article.readingtime }} m.
59
+ {{ formatMessage(messages.articlePreview.readingTime, { minutes: String(article.readingtime ?? '') }) }}
60
60
  </p>
61
61
  </div>
62
62
  </a>
@@ -32,6 +32,8 @@
32
32
  * the stylesheet. Standing on tokens instead means this bar renders the same in
33
33
  * both, and a consumer needs no global class layer to install it.
34
34
  */
35
+ import { useBrandMessages } from '../lib/i18n/messages.ts';
36
+
35
37
  export interface NavItem {
36
38
  /** Link text, and the value `current` is matched against. */
37
39
  name: string;
@@ -71,10 +73,12 @@ withDefaults(defineProps<{
71
73
  logoHref: "/",
72
74
  logoAlt: "CODECAVE",
73
75
  });
76
+
77
+ const messages = useBrandMessages();
74
78
  </script>
75
79
 
76
80
  <template>
77
- <nav class="brand-nav" aria-label="Main">
81
+ <nav class="brand-nav" :aria-label="messages.brandNav.label">
78
82
  <div class="brand-nav-inner">
79
83
  <ul>
80
84
  <li v-for="item in left" :key="item.name" :class="{ dropdown: item.slot, badged: item.badge }">
@@ -99,9 +99,9 @@ onMounted(() => {
99
99
  @mousemove="handleMouseMove"
100
100
  @mouseenter="handleMouseEnter"
101
101
  @mouseleave="handleMouseLeave"
102
- class="cursor-pointer flex items-center justify-center relative z-[1] overflow-hidden w-full h-12 px-6 py-1 rounded-full bg-glow-25"
102
+ class="cursor-pointer flex items-center justify-center relative z-[1] overflow-hidden w-full min-h-12 px-6 py-1 rounded-full bg-glow-25"
103
103
  >
104
- <p class="relative z-[2] text-primary-800 font-bold select-none">
104
+ <p class="relative z-[2] text-center text-primary-800 font-bold select-none">
105
105
  {{ title }}
106
106
  </p>
107
107
 
@@ -18,27 +18,35 @@ defineEmits(['update:modelValue'])
18
18
 
19
19
  <template>
20
20
  <div class="w-full relative">
21
- <label :for="id" class="absolute flex items-center gap-0.5 pl-3 pt-3 font-bold text-heading text-sm">
22
- <span>{{ label }}</span>
23
- <AsteriskIcon v-if="isRequired" />
24
- </label>
21
+ <!-- The box is this wrapper rather than the input, and the label sits in
22
+ the flow above the input rather than floating over its top padding, as
23
+ TextField's does: a label that wraps, as a translated one in half a
24
+ form's width does, now pushes the input down instead of covering what
25
+ was typed. -mb-1 keeps a one-line field 66px high with its text where
26
+ it was. -->
27
+ <div :class="`input-box flex flex-col bg-surface-secondary rounded-lg border border-surface-tertiary
28
+ hover:bg-surface-tertiary transition-colors ${isError ? 'input-error' : ''}`">
29
+ <label :for="id" class="flex items-center gap-0.5 px-3 pt-3 -mb-1 font-bold text-heading text-sm">
30
+ <span>{{ label }}</span>
31
+ <AsteriskIcon v-if="isRequired" />
32
+ </label>
25
33
 
26
- <input
27
- :id="id"
28
- :type="type"
29
- :autocomplete="autocomplete"
30
- :placeholder="placeholder"
31
- :required="isRequired"
32
- :value="modelValue"
33
- :aria-invalid="isError ? 'true' : undefined"
34
- :aria-describedby="isError ? `${id}-error` : undefined"
35
- :class="`
36
- w-full p-3 pt-7 bg-surface-secondary rounded-lg placeholder:text-xs placeholder:text-body-secondary
37
- focus:outline-none transition-colors border border-surface-tertiary
38
- hover:bg-surface-tertiary
39
- ${isError ? 'text-error input-error focus:text-error' : 'text-hovered focus:text-hovered'}
40
- `" />
41
- <span v-if="isError" class="text-error text-xs">
34
+ <input
35
+ :id="id"
36
+ :type="type"
37
+ :autocomplete="autocomplete"
38
+ :placeholder="placeholder"
39
+ :required="isRequired"
40
+ :value="modelValue"
41
+ :aria-invalid="isError ? 'true' : undefined"
42
+ :aria-describedby="isError ? `${id}-error` : undefined"
43
+ :class="`
44
+ w-full px-3 pb-3 bg-transparent rounded-b-lg placeholder:text-xs placeholder:text-body-secondary
45
+ focus:outline-none
46
+ ${isError ? 'text-error focus:text-error' : 'text-hovered focus:text-hovered'}
47
+ `" />
48
+ </div>
49
+ <span v-if="isError" :id="`${id}-error`" class="text-error text-xs">
42
50
  {{ errorMessage }}
43
51
  </span>
44
52
  </div>
@@ -54,13 +62,19 @@ input:-webkit-autofill:active {
54
62
  -webkit-background-clip: text !important;
55
63
  }
56
64
 
57
- input:focus {
65
+ /* The autofill colour above paints the input alone, so the label's strip of the
66
+ * box takes the same colour. */
67
+ .input-box:has(input:-webkit-autofill) {
68
+ background-color: var(--color-surface-tertiary);
69
+ }
70
+
71
+ .input-box:focus-within {
58
72
  box-shadow: 0 0 16px 0 hsl(from var(--color-brand-500) h s l / 0.5),
59
73
  0 0 4px 0 hsl(from var(--color-brand-500) h s l / 0.6);
60
74
  }
61
75
 
62
- input.input-error:focus,
63
- .input-error {
76
+ .input-error,
77
+ .input-error:focus-within {
64
78
  box-shadow: 0 0 16px 0 hsl(from var(--color-error-200) h s l / 0.5),
65
79
  0 0 4px 0 hsl(from var(--color-error-100) h s l / 0.6);
66
80
  }
@@ -3,6 +3,7 @@ import LinkedinIcon from "../assets/icons/linkedin-icon.vue";
3
3
  import VerifiedIcon from "../assets/icons/verified-icon.vue";
4
4
  import LazyImage from "./images/LazyImage.vue"
5
5
  import { isCorrectLinkedInFormat } from "../helpers/form-validator.ts";
6
+ import { formatMessage, useBrandMessages } from "../lib/i18n/messages.ts";
6
7
 
7
8
  /* See ArticlePreview.vue: the fields read here, not the generated Strapi
8
9
  * `Testimonial`, which a caller may still pass unchanged. */
@@ -23,6 +24,7 @@ const props = defineProps<{
23
24
  }>()
24
25
 
25
26
  const imageUrl = (url: string) => props.resolveImage?.(url) ?? url
27
+ const messages = useBrandMessages()
26
28
  </script>
27
29
 
28
30
  <template>
@@ -39,6 +41,7 @@ const imageUrl = (url: string) => props.resolveImage?.(url) ?? url
39
41
  target="_blank"
40
42
  rel='noopener noreferrer'
41
43
  class="text-default-transparent hover:text-action transition-colors"
44
+ :aria-label="formatMessage(messages.review.linkedin, { name: item.name })"
42
45
  :href="item.linkedinurl">
43
46
  <component
44
47
  :is="LinkedinIcon"
@@ -2,6 +2,7 @@
2
2
  import { computed, ref, onMounted, nextTick } from 'vue';
3
3
  import Shevron from '../assets/icons/shevron.vue';
4
4
  import { type MenuItem } from '../lib/menus/menuTypes.ts';
5
+ import { useBrandMessages } from '../lib/i18n/messages.ts';
5
6
 
6
7
  interface Props {
7
8
  section?: string;
@@ -10,6 +11,7 @@ interface Props {
10
11
  }
11
12
 
12
13
  const props = defineProps<Props>();
14
+ const messages = useBrandMessages();
13
15
 
14
16
  const openItem = ref<string | null>(null);
15
17
 
@@ -102,7 +104,7 @@ onMounted(async () => {
102
104
  </script>
103
105
 
104
106
  <template>
105
- <nav v-if="items.length" class="ds-subnav" aria-label="Section">
107
+ <nav v-if="items.length" class="ds-subnav" :aria-label="messages.subNavbar.label">
106
108
  <div class="ds-subnav-inner" :class="{ 'is-submenu-open': openItem }">
107
109
  <ul>
108
110
  <li v-for="item in items" :key="item.name" :ref="el => setItemRef(item.name, el)">
@@ -15,6 +15,7 @@ import type {
15
15
  ICrmFormClient,
16
16
  } from "../../lib/crm/types";
17
17
  import { isCorrectEmailFormat, isCorrectLinkedInFormat } from "../../helpers/form-validator.ts";
18
+ import { formatMessage, useBrandMessages } from "../../lib/i18n/messages.ts";
18
19
 
19
20
  /* This component knows nothing about HubSpot, or about any other CRM. It is
20
21
  * handed the labels to render and something that can accept a submission; who
@@ -24,6 +25,8 @@ const props = defineProps<{
24
25
  client: ICrmFormClient
25
26
  }>()
26
27
 
28
+ const messages = useBrandMessages()
29
+
27
30
  const emit = defineEmits<{
28
31
  (e: 'submit', values: ContactFormValues): void
29
32
  (e: 'submitted', result: CrmSubmitResult): void
@@ -86,15 +89,15 @@ const validateForm = () => {
86
89
  let linkedInPage = formData.value.linkedinCompanyPage;
87
90
 
88
91
  if (email.value !== '' && !isCorrectEmailFormat(email.value)) {
89
- email.error = 'Please enter valid email'
92
+ email.error = messages.contactForm.invalidEmail
90
93
  isValid = false
91
94
  }
92
95
  if (linkedInPage.value !== '' && !isCorrectLinkedInFormat(linkedInPage.value)) {
93
- linkedInPage.error = 'Please enter valid LinkedIn page link'
96
+ linkedInPage.error = messages.contactForm.invalidLinkedIn
94
97
  isValid = false
95
98
  }
96
99
  if (description.maxLength !== undefined && description.value.length > description.maxLength) {
97
- description.error = `You faced characters limits. Max length is ${description.maxLength}`
100
+ description.error = formatMessage(messages.contactForm.tooLong, { max: description.maxLength })
98
101
  isValid = false
99
102
  }
100
103
 
@@ -110,7 +113,7 @@ const validateRequiredFields = () => {
110
113
  const isEmpty = field.value === '' || field.value === false
111
114
 
112
115
  if (field.required && isEmpty) {
113
- field.error = 'This field is required'
116
+ field.error = messages.contactForm.required
114
117
  isValid = false
115
118
  } else {
116
119
  field.error = ''
@@ -177,8 +180,8 @@ const alertKind = ref<'success' | 'error' | null>(null)
177
180
  const isAlertVisible = computed(() => alertKind.value !== null)
178
181
 
179
182
  const ALERT_TEXT = {
180
- success: 'Sent! We will contact you within next 1-3 business days.',
181
- error: 'We could not send your request. Please try again, or email us at hello@codecave.pro.',
183
+ success: messages.contactForm.sent,
184
+ error: messages.contactForm.notSent,
182
185
  } as const
183
186
 
184
187
  const showAlert = (kind: 'success' | 'error') => {
@@ -205,7 +208,7 @@ const validateField = (field) => {
205
208
  }
206
209
 
207
210
  if (field.required && !field.value) {
208
- field.error = 'This field is required'
211
+ field.error = messages.contactForm.required
209
212
  return
210
213
  }
211
214
 
@@ -233,8 +236,14 @@ fields.forEach((key) => {
233
236
  )
234
237
  })
235
238
 
236
- const companyNamePattern = /[^\p{L}\p{N} &]/u;
237
- const namePattern = /[^\p{L} ]/u;
239
+ /* What each field refuses as it is typed. A letter includes its combining
240
+ * marks (\p{M}), and a name keeps its hyphen, apostrophe and period, so
241
+ * "Anne-Marie O'Neil Jr." survives. A company name also keeps the punctuation
242
+ * legal names are written with: the quotes of ООО «Ромашка», and commas,
243
+ * parentheses, slashes and plus signs. Both used to allow letters and spaces
244
+ * alone. */
245
+ const companyNamePattern = /[^\p{L}\p{M}\p{N} &.,'’"«»„“”()+\/!-]/u;
246
+ const namePattern = /[^\p{L}\p{M} .'’-]/u;
238
247
 
239
248
  const createSanitizer = (pattern) => {
240
249
  return (value) => value.replace(pattern, '');
@@ -279,7 +288,7 @@ const preventInvalidInput = (event, pattern) => {
279
288
  <Checkbox id="promotions" v-model="formData.communicationConsent.value" :label="formData.communicationConsent.label" :isRequired="formData.communicationConsent.required" :isError="!!formData.communicationConsent.error" />
280
289
  </div>
281
290
  </div>
282
- <GlowButton @click="submitContactsForm" title="Leave consultation request" class="self-center lg:self-start" />
291
+ <GlowButton @click="submitContactsForm" :title="messages.contactForm.submit" class="self-center lg:self-start" />
283
292
  </form>
284
293
  <div v-show="isAlertVisible" class="border border-action group absolute bottom-19.25 md:fixed z-350 left-1/2 -translate-x-1/2 md:top-40 form-alert flex gap-2 md:gap-8 items-center justify-between w-full h-fit max-w-139 bg-surface-secondary p-4 md:px-6 md:py-2.5 rounded-3xl">
285
294
  <div v-if="alertKind === 'success'" class="w-8 h-8">
@@ -288,7 +297,7 @@ const preventInvalidInput = (event, pattern) => {
288
297
  <p class="text-sm md:text-lg font-bold text-heading md:max-w-sm ">
289
298
  {{ alertKind ? ALERT_TEXT[alertKind] : '' }}
290
299
  </p>
291
- <button @click="alertKind = null" class="group-hover:text-action text-heading transition-colors cursor-pointer">
300
+ <button @click="alertKind = null" :aria-label="messages.contactForm.closeAlert" class="group-hover:text-action text-heading transition-colors cursor-pointer">
292
301
  <component :is="CloseIcon" />
293
302
  </button>
294
303
  </div>
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import Button from "./Button.vue";
3
3
  import { computed } from 'vue'
4
+ import { useBrandMessages } from '../lib/i18n/messages.ts'
4
5
 
5
6
  const props = defineProps<{
6
7
  active: boolean
@@ -10,6 +11,8 @@ const props = defineProps<{
10
11
  index?: number
11
12
  }>()
12
13
 
14
+ const messages = useBrandMessages()
15
+
13
16
  const rotate = [
14
17
  '-rotate-4 left-2 top-10',
15
18
  '-rotate-1 left-1/4 top-20',
@@ -46,10 +49,10 @@ const cardClass = computed(() => [
46
49
  <template>
47
50
  <div :class="cardClass">
48
51
  <div class="card flex flex-col items-center justify-around">
49
- <h3 class="max-w-[8rem] text-center text-xl font-bold text-heading text-balance">
52
+ <h3 class="w-min min-w-[8rem] max-w-full text-center text-xl font-bold text-heading text-balance">
50
53
  {{ name }}
51
54
  </h3>
52
- <Button as="link" :href="href ?? ''" title="Explore service" variant="tertiary" :class="`${active ? 'block' : 'hidden xl:block xl:opacity-0'}`" />
55
+ <Button as="link" :href="href ?? ''" :title="messages.technologyCard.explore" variant="tertiary" :class="`${active ? 'block' : 'hidden xl:block xl:opacity-0'}`" />
53
56
  </div>
54
57
  </div>
55
58
  </template>
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import Button from "../common/Button.vue";
3
+ import { useId } from "vue";
3
4
 
4
5
  /* Declared here rather than imported from ./links.ts, which is this site's
5
6
  * footer content. TypeScript is structural, so every existing caller still
@@ -15,11 +16,17 @@ defineProps<{
15
16
  groupName: string
16
17
  items: LinkItem[]
17
18
  }>()
19
+
20
+ /* The heading's id, generated rather than built from groupName: a group name
21
+ * is display text, and "Отзывы о нас" has spaces, which an id cannot hold, so
22
+ * aria-labelledby named nothing. Astro gives each rendered Vue app its own
23
+ * idPrefix, so two groups on one page never collide. */
24
+ const labelId = useId()
18
25
  </script>
19
26
 
20
27
  <template>
21
- <nav :aria-labelledby="`${groupName}-label`" class="space-y-4">
22
- <p :id="`${groupName}-label`" class="text-body-secondary uppercase font-bold text-xs" >
28
+ <nav :aria-labelledby="labelId" class="space-y-4">
29
+ <p :id="labelId" class="text-body-secondary uppercase font-bold text-xs" >
23
30
  {{ groupName }}
24
31
  </p>
25
32
  <ul class="space-y-3 xl:space-y-2 text-sm">
@@ -4,6 +4,7 @@ import Shevron from "../assets/icons/shevron.vue";
4
4
  import { computed, onUnmounted, ref, watch } from "vue";
5
5
  import BackIcon from "../assets/icons/back-icon.vue";
6
6
  import ServicesList from "./services-list.vue";
7
+ import { useBrandMessages } from "../lib/i18n/messages.ts";
7
8
 
8
9
  /* Everything this drawer used to read out of menu.ts, paths.ts and the site's
9
10
  * logo asset now arrives as props, so it reaches no route table and no asset
@@ -26,6 +27,8 @@ const props = defineProps<{
26
27
  homeHref?: string
27
28
  }>()
28
29
 
30
+ const messages = useBrandMessages()
31
+
29
32
  const servicesItem = computed(() => props.items.find((i) => i.submenu?.length))
30
33
 
31
34
  const isMenuOpen = ref(false)
@@ -81,7 +84,7 @@ const handleCloseMenu = () => {
81
84
  <a :href="homeHref" class="px-1.5 hover:opacity-80 transition-opacity">
82
85
  <img :src="logo" alt="CODECAVE" />
83
86
  </a>
84
- <button @click="isMenuOpen = !isMenuOpen" :class="`burger-menu ${isMenuOpen ? 'text-action' : 'text-heading'} transition-colors`">
87
+ <button @click="isMenuOpen = !isMenuOpen" :aria-label="messages.mobileMenu.toggle" :aria-expanded="isMenuOpen" :class="`burger-menu ${isMenuOpen ? 'text-action' : 'text-heading'} transition-colors`">
85
88
  <span></span>
86
89
  </button>
87
90
  </div>
@@ -94,7 +97,7 @@ const handleCloseMenu = () => {
94
97
  <slot name="services" />
95
98
  </div>
96
99
 
97
- <button class="pt-4 shrink-0" @click="isServicesOpen = false">
100
+ <button class="pt-4 shrink-0" :aria-label="messages.mobileMenu.back" @click="isServicesOpen = false">
98
101
  <component :is="BackIcon" class="w-11 h-11 text-action" />
99
102
  </button>
100
103
  </div>
@@ -0,0 +1,129 @@
1
+ /* The text brand components render that no caller passes in: validation
2
+ * errors, button titles, landmark names and the names of icon-only controls.
3
+ *
4
+ * English is built in, so a consumer that provides nothing renders what these
5
+ * components always rendered. A site in another language hands its translation
6
+ * to provideBrandMessages() once, from its Vue app entrypoint, instead of
7
+ * threading one prop per string through every call site.
8
+ *
9
+ * WHY THE APP ENTRYPOINT AND NOT A PARENT COMPONENT. Astro renders every island,
10
+ * and every component it renders without a client directive, as a separate Vue
11
+ * app, so a provide() in one component reaches nothing in the next. The app
12
+ * entrypoint is the one place each of those apps passes through, on the server
13
+ * and in the browser alike:
14
+ *
15
+ * // astro.config.mjs
16
+ * vue({ appEntrypoint: '/src/pages/_app' })
17
+ *
18
+ * // src/pages/_app.ts
19
+ * export default (app: App) => provideBrandMessages(app, russian)
20
+ *
21
+ * A {name} in a string is filled by formatMessage(). A translation keeps every
22
+ * placeholder and puts it wherever its grammar needs. */
23
+ import { inject, type App, type InjectionKey } from 'vue'
24
+
25
+ export interface BrandMessages {
26
+ contactForm: {
27
+ /** A required field left empty. */
28
+ required: string
29
+ invalidEmail: string
30
+ invalidLinkedIn: string
31
+ /** {max}: the field's character limit. */
32
+ tooLong: string
33
+ /** The submit button. */
34
+ submit: string
35
+ /** The alert after a submission the CRM accepted. */
36
+ sent: string
37
+ /** The alert after a submission that failed. */
38
+ notSent: string
39
+ /** Accessible name of the button that dismisses either alert. */
40
+ closeAlert: string
41
+ }
42
+ articlePreview: {
43
+ /** {minutes}: the article's reading time. */
44
+ readingTime: string
45
+ }
46
+ technologyCard: {
47
+ /** The link to the service a card names. */
48
+ explore: string
49
+ }
50
+ brandNav: {
51
+ /** Accessible name of the top bar's navigation landmark. */
52
+ label: string
53
+ }
54
+ subNavbar: {
55
+ /** Accessible name of the section bar's navigation landmark. */
56
+ label: string
57
+ }
58
+ mobileMenu: {
59
+ /** Accessible name of the burger button that opens and closes the drawer. */
60
+ toggle: string
61
+ /** Accessible name of the button that leaves the services list. */
62
+ back: string
63
+ }
64
+ review: {
65
+ /** {name}: the reviewer. Accessible name of the link to their LinkedIn. */
66
+ linkedin: string
67
+ }
68
+ }
69
+
70
+ /** Any subset of the strings, by group. What is left out stays English. */
71
+ export type BrandMessagesOverride = {
72
+ [Group in keyof BrandMessages]?: Partial<BrandMessages[Group]>
73
+ }
74
+
75
+ export const ENGLISH_BRAND_MESSAGES: BrandMessages = {
76
+ contactForm: {
77
+ required: 'This field is required',
78
+ invalidEmail: 'Please enter valid email',
79
+ invalidLinkedIn: 'Please enter valid LinkedIn page link',
80
+ tooLong: 'You faced characters limits. Max length is {max}',
81
+ submit: 'Leave consultation request',
82
+ sent: 'Sent! We will contact you within next 1-3 business days.',
83
+ notSent: 'We could not send your request. Please try again, or email us at hello@codecave.pro.',
84
+ closeAlert: 'Close',
85
+ },
86
+ articlePreview: {
87
+ readingTime: 'Reading time: {minutes} m.',
88
+ },
89
+ technologyCard: {
90
+ explore: 'Explore service',
91
+ },
92
+ brandNav: {
93
+ label: 'Main',
94
+ },
95
+ subNavbar: {
96
+ label: 'Section',
97
+ },
98
+ mobileMenu: {
99
+ toggle: 'Menu',
100
+ back: 'Back',
101
+ },
102
+ review: {
103
+ linkedin: '{name} on LinkedIn',
104
+ },
105
+ }
106
+
107
+ /* Symbol.for rather than Symbol(): the key has to be the same value in the
108
+ * module the site's entrypoint imports and the one each component imports, and
109
+ * a consumer that aliases this package at a local build can load it twice. */
110
+ const KEY = Symbol.for('codecave.brand.messages') as InjectionKey<BrandMessages>
111
+
112
+ /** Makes every brand component in `app` render `messages`, English where they leave a string out. */
113
+ export const provideBrandMessages = (app: App, messages: BrandMessagesOverride): void => {
114
+ const merged = Object.fromEntries(
115
+ Object.entries(ENGLISH_BRAND_MESSAGES).map(([group, strings]) => [
116
+ group,
117
+ { ...strings, ...messages[group as keyof BrandMessages] },
118
+ ]),
119
+ ) as unknown as BrandMessages
120
+ app.provide(KEY, merged)
121
+ }
122
+
123
+ /** The strings a component renders. Call it in setup. */
124
+ export const useBrandMessages = (): BrandMessages => inject(KEY, ENGLISH_BRAND_MESSAGES)
125
+
126
+ /** Fills the {name} placeholders of a message. A placeholder with no value is left as written. */
127
+ export const formatMessage = (template: string, values: Record<string, string | number>): string =>
128
+ template.replace(/\{(\w+)\}/g, (placeholder, name: string) =>
129
+ Object.hasOwn(values, name) ? String(values[name]) : placeholder)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codecavepro/brand",
3
- "version": "3.2.6",
3
+ "version": "3.3.0",
4
4
  "description": "CODECAVE design system — colour, typography and layout tokens as CSS custom properties and as a typed module.",
5
5
  "license": "Unlicense",
6
6
  "type": "module",