@codecavepro/brand 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -142,8 +142,8 @@ Two things to know about it:
142
142
 
143
143
  ## The components
144
144
 
145
- 15 components and 13 icons — the buttons, form controls, menu, footer and cards
146
- codecave.pro renders — ship as **source**, byte-for-byte the files the site builds
145
+ 19 components and 13 icons — the buttons, form controls, menu, footer, and the
146
+ article, testimonial and technology cards codecave.pro renders — ship as **source**, byte-for-byte the files the site builds
147
147
  from. Not a reimplementation of them, and not a bundle compiled from a snapshot:
148
148
  the same files, kept identical by a check that runs on every push.
149
149
 
@@ -166,33 +166,53 @@ plugin — `@vitejs/plugin-vue`, `@astrojs/vue`, `vue-loader`. In exchange their
166
166
  styles go through your own pipeline and you can read the source of anything that
167
167
  surprises you.
168
168
 
169
- `vue` is a peer dependency. `gsap` is an optional one, wanted only by `GlowButton`,
170
- `TypingEffect` and `ContactUsForm`; leave it out and the rest are unaffected.
169
+ `vue` is the only required peer. Four more are optional, each wanted by one or two
170
+ components; leave one out and only those are affected — `gsap` by `GlowButton.vue`,
171
+ `effects/TypingEffect.vue` and `forms/ContactUsForm.vue`; `vue3-carousel` by
172
+ `homepage/technologies.vue`; `marked` and `isomorphic-dompurify` by
173
+ `project/pain-points-item.vue`.
171
174
 
172
- ### They need your Tailwind theme, not just the tokens
175
+ ### Import the theme, not just the tokens
173
176
 
174
177
  Every colour, radius and control height in these components is a token — but they reach
175
178
  most of them through **Tailwind utility classes** (`bg-surface-primary`,
176
- `text-heading-lg`, `rounded-card`), and a utility class only exists if Tailwind knows
177
- the name. Importing `@codecavepro/brand/tokens.css` gives you the *values*; it does not
178
- give Tailwind the `@theme` entries that turn them into classes.
179
+ `text-heading-md`, `rounded-custom`), and a utility class exists only if Tailwind knows
180
+ the name. `tokens.css` gives you the *values*; `theme.css` gives Tailwind the names, so
181
+ you need both:
179
182
 
180
- Until this package ships that block, mirror it in your own stylesheet — the definitive
181
- copy is the `@theme { … }` in codecave.pro's `src/styles/global.css`, reproduced in this
182
- repository at `docs/source_examples/styles/global.css`. Without it the components mount
183
- and behave correctly and render nearly unstyled.
183
+ ```css
184
+ @import "tailwindcss";
185
+ @import "@codecavepro/brand/tokens.css";
186
+ @import "@codecavepro/brand/theme.css";
187
+ ```
188
+
189
+ **That order is load-bearing, and `tokens.css` must stay unlayered.** Several entries in
190
+ `theme.css` are deliberate self-references (`--color-glow-25: var(--color-glow-25)`):
191
+ the declaration is what makes Tailwind emit the utility, while the value comes from
192
+ `tokens.css`, whose unlayered `:root` outranks `@layer theme`. Wrap `tokens.css` in a
193
+ cascade layer — or leave it out — and those names resolve to nothing.
194
+
195
+ Import only `tokens.css` and the components mount and behave correctly and render
196
+ nearly unstyled.
184
197
 
185
- Tracked as [CCWEB2-333](https://codecave.atlassian.net/browse/CCWEB2-333).
198
+ ### The content-shaped four resolve their own image URLs
186
199
 
187
- ### What is not here, and why
200
+ `ArticlePreview`, `Review`, `pain-points-item` and `technologies` render CMS content, so
201
+ they used to reach a helper that knew codecave.pro's CMS host. They no longer do. The
202
+ three that show images take an optional resolver and default to leaving the URL alone:
203
+
204
+ ```vue
205
+ <!-- URLs already absolute — nothing to pass -->
206
+ <Review :item="testimonial" />
207
+
208
+ <!-- URLs relative to your own media host -->
209
+ <Review :item="testimonial" :resolve-image="(u) => `https://cdn.example.com/${u}`" />
210
+ ```
188
211
 
189
- Four components stay out: `ArticlePreview`, `Review`, `pain-points-item` and
190
- `technologies`. They are CMS-shaped rather than brand-shaped their props are types
191
- generated from the Strapi schema, and they build image URLs through a helper that reads
192
- a hardcoded CMS host and token. Shipping them would put the site's content layer inside
193
- a brand package. Inverting that dependency site-side is
194
- [CCWEB2-332](https://codecave.atlassian.net/browse/CCWEB2-332); they arrive when it
195
- lands.
212
+ Their props are declared as the fields each one reads — `item.photo.url`,
213
+ `article.cover.url` not as a generated CMS schema. Anything with those fields
214
+ satisfies them, so a Strapi entity, a Contentful entry or a plain object all work
215
+ unchanged.
196
216
 
197
217
  ## Controls are 48px, and it is a floor
198
218
 
@@ -220,8 +240,11 @@ the rules, the reasoning, the anti-patterns — is documented separately:
220
240
 
221
241
  It authors nothing. Every byte is copied, extracted or compiled out of `docs/` in the
222
242
  repository above: `css` and `fonts.css` are copied verbatim, `tokens.css` is extracted
223
- from the same file `css` is copied from, and the typed module is compiled from
224
- `docs/tokens/*.ts`. `docs/` stays the single origin, and CI asserts on every push that
243
+ from the same file `css` is copied from, `theme.css` is extracted from the capture of
244
+ codecave.pro's `global.css`, and the typed module is compiled from `docs/tokens/*.ts`.
245
+ A name that `theme.css` and `tokens.css` both give a literal value must agree, and the
246
+ build fails if it does not — otherwise one of the two would be dead and nobody would
247
+ see it. `docs/` stays the single origin, and CI asserts on every push that
225
248
  the copies are byte-identical and the extraction still reproduces what shipped.
226
249
 
227
250
  Two editable copies of a palette is the exact failure this package exists to end, so it
@@ -0,0 +1,58 @@
1
+ <script setup lang="ts">
2
+ import { formattedDate } from "../../helpers/date-formatter.ts";
3
+ import { paths } from "../../helpers/paths.ts";
4
+
5
+ /* The fields this component actually reads, instead of the generated Strapi
6
+ * `Article`. TypeScript is structural, so a real Article still satisfies this
7
+ * -- callers pass exactly what they passed before -- but the component no
8
+ * longer carries the CMS schema with it. */
9
+ type ArticleSummary = {
10
+ slug: string | null
11
+ title: string
12
+ excerpt: string
13
+ date: Date | null
14
+ locale: string | null
15
+ readingtime: unknown
16
+ cover: { url: string; name: string }
17
+ }
18
+
19
+ const props = defineProps<{
20
+ article: ArticleSummary
21
+ className?: string
22
+ /* CMS media URLs arrive relative ("uploads/x.png"). The site injects its own
23
+ * resolver; a caller whose URLs are already absolute passes nothing. */
24
+ resolveImage?: (url: string) => string
25
+ }>()
26
+
27
+ const imageUrl = (url: string) => props.resolveImage?.(url) ?? url
28
+ </script>
29
+
30
+ <template>
31
+ <a :href="`${paths.insights}/${article.slug}`" :class="`mx-1 lg:mx-2 w-full h-full self-start sm:self-auto p-6 flex flex-col gap-5 sm:gap-8
32
+ rounded-[2.25rem] bg-surface-secondary hover:bg-surface-secondary transition-colors cursor-pointer border-surface-tertiary border
33
+ ${className ?? ''}`">
34
+ <div class="flex flex-col sm:flex-row gap-5 sm:gap-8 h-fit">
35
+ <img loading="lazy" class="sm:w-[132px] sm:h-[132px] rounded-xl object-cover"
36
+ :src="imageUrl(article.cover.url)"
37
+ :alt="article.cover.name"
38
+ :width="100"
39
+ :height="100"/>
40
+ <div class="space-y-2 sm:space-y-3">
41
+ <time class="text-body-secondary text-xs sm:text-sm" :datetime="article.date?.toString()">
42
+ {{ formattedDate(article.locale, article.date) }}
43
+ </time>
44
+ <h3 class="font-bold text-lg md:text-xl text-heading">
45
+ {{ article.title }}
46
+ </h3>
47
+ </div>
48
+ </div>
49
+ <div class="text-sm flex flex-col justify-between h-full">
50
+ <p class="text-body-secondary-lighter">
51
+ {{ article.excerpt }}
52
+ </p>
53
+ <p class="text-xs sm:text-sm text-body-secondary mt-6 sm:mt-7">
54
+ Reading time: {{ article.readingtime }} m.
55
+ </p>
56
+ </div>
57
+ </a>
58
+ </template>
@@ -0,0 +1,64 @@
1
+ <script setup lang="ts">
2
+ import LinkedinIcon from "../../assets/icons/linkedin-icon.vue";
3
+ import VerifiedIcon from "../../assets/icons/verified-icon.vue";
4
+ import LazyImage from "./images/LazyImage.vue"
5
+
6
+ /* See ArticlePreview.vue: the fields read here, not the generated Strapi
7
+ * `Testimonial`, which a caller may still pass unchanged. */
8
+ type TestimonialCard = {
9
+ name: string
10
+ position: string
11
+ review: string
12
+ verification: string | null
13
+ linkedinurl: string
14
+ photo: { url: string; name: string }
15
+ }
16
+
17
+ const props = defineProps<{
18
+ item: TestimonialCard
19
+ className?: string
20
+ /* Injected by the site; omit it when the URLs are already absolute. */
21
+ resolveImage?: (url: string) => string
22
+ }>()
23
+
24
+ const imageUrl = (url: string) => props.resolveImage?.(url) ?? url
25
+ </script>
26
+
27
+ <template>
28
+ <div :class="`mx-1 lg:mx-2 testimonial rounded-custom space-y-2 lg:space-y-3 py-10 px-6 lg:px-11 ${className}`">
29
+ <div class="flex flex-col lg:flex-row gap-5 lg:items-center">
30
+ <LazyImage v-if="item.photo.name !== 'no-image.svg'"
31
+ class="w-12 lg:w-16 h-12 lg:h-16" :src="imageUrl(item.photo.url)"
32
+ :alt=item.photo.name />
33
+ <div>
34
+ <div class="flex items-center gap-2">
35
+ <p class="text-heading text-lg lg:text-xl">{{ item.name }}</p>
36
+ <a
37
+ v-if="item.linkedinurl.length > 1"
38
+ target="_blank"
39
+ rel='noopener noreferrer'
40
+ class="text-default-transparent hover:text-action transition-colors"
41
+ :href="item.linkedinurl">
42
+ <component
43
+ :is="LinkedinIcon"
44
+ />
45
+ </a>
46
+ </div>
47
+ <p class="text-sm text-body-secondary">{{ item.position }}</p>
48
+ </div>
49
+ </div>
50
+ <p class="text-sm text-body-secondary-lighter whitespace-pre-wrap">
51
+ {{ item.review }}
52
+ </p>
53
+ <div v-if="item.verification" class="flex items-center gap-1 text-action">
54
+ <VerifiedIcon />
55
+ <span>{{ item.verification }}</span>
56
+ </div>
57
+ </div>
58
+ </template>
59
+
60
+ <style scoped>
61
+ .testimonial {
62
+ backdrop-filter: blur(32px);
63
+ }
64
+ </style>
@@ -0,0 +1,122 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from "vue";
3
+ import { Carousel, type CarouselConfig, Slide } from "vue3-carousel";
4
+ import "vue3-carousel/carousel.css";
5
+ import { BREAKPOINTS } from "../../helpers/breakpoints.ts";
6
+ import GlowButton from "../common/GlowButton.vue";
7
+ import TypingEffect from "../common/effects/TypingEffect.vue";
8
+ import TechnologyCard from "./technology-card.vue";
9
+ import { paths } from "../../helpers/paths.ts";
10
+
11
+ /* See ArticlePreview.vue: the fields read here, not the generated Strapi
12
+ * `Technology`, which a caller may still pass unchanged. This component
13
+ * renders no images, so it needs no resolver. */
14
+ type TechnologySummary = {
15
+ name: string
16
+ title1: string
17
+ title2: string
18
+ }
19
+
20
+ const props = defineProps<{
21
+ technologies: TechnologySummary[]
22
+ hideSelected?: boolean
23
+ }>()
24
+
25
+ const activeIndex = ref(0)
26
+ const defaultAutoplayValue = 12000
27
+ const autoplay = ref(defaultAutoplayValue)
28
+ const handleMouseOver = (index: number) => {
29
+ activeIndex.value = index
30
+ autoplay.value = 0
31
+ }
32
+ const selectedTechnology = computed(() => {
33
+ return props.technologies[activeIndex.value]
34
+ })
35
+ const handleMouseLeave = () => {
36
+ activeIndex.value = 0
37
+ autoplay.value = defaultAutoplayValue
38
+ }
39
+ const config = computed<Partial<CarouselConfig>>(() => ({
40
+ gap: 8,
41
+ itemsToShow: 'auto',
42
+ height: 220,
43
+ autoplay: autoplay.value,
44
+ snapAlign: 'center',
45
+ pauseAutoplayOnHover: true,
46
+ wrapAround: true,
47
+ transition: 500,
48
+ breakpoints: {
49
+ [BREAKPOINTS.md]: {
50
+ gap: 16,
51
+ },
52
+ [BREAKPOINTS.xl]: {
53
+ enabled: false,
54
+ },
55
+ }
56
+ }))
57
+ </script>
58
+
59
+ <template>
60
+ <section :class="`page-container
61
+ ${hideSelected ? 'pt-8' : 'flex flex-col pt-4 md:pt-14 pb-20 xl:pb-0 swipe-over'}`">
62
+ <div v-if="!hideSelected" class="flex flex-col items-center text-center">
63
+ <div class="space-y-3">
64
+ <div class="flex justify-center gap-6 font-bold text-xl">
65
+ <div class="flex flex-col xl:flex-row xl:gap-2">
66
+ <span class="text-body-primary">15+</span>
67
+ <span class="text-body-secondary-lighter">Years of experience</span>
68
+ </div>
69
+ <div class="flex flex-col xl:flex-row xl:gap-2">
70
+ <span class="text-body-primary">4.8</span>
71
+ <span class="text-body-secondary-lighter">Average rating</span>
72
+ </div>
73
+ </div>
74
+ <TypingEffect
75
+ :text1="selectedTechnology.title1"
76
+ :text2="selectedTechnology.title2"
77
+ />
78
+ </div>
79
+ </div>
80
+ <div v-if="!hideSelected" class="self-center pt-10 pb-[4.5rem] md:pt-14 md:pb-16">
81
+ <GlowButton title="Get a free consultation" :href="paths.contactUs" />
82
+ </div>
83
+ <div v-else class="pt-[7.5rem] pb-20 space-y-7 text-center">
84
+ <h2 class="font-bold text-heading-md text-heading">
85
+ Looking for something specific?
86
+ </h2>
87
+ <p class="text-xl mx-auto max-w-lg text-body-secondary-lighter">
88
+ We offer a variety of services that might be a game-changer for your business.
89
+ </p>
90
+ </div>
91
+ <div class="hidden xl:block relative h-80">
92
+ <div
93
+ v-for="(item, index) in technologies"
94
+ :key="index"
95
+ @mouseover="handleMouseOver(index)"
96
+ @mouseleave="handleMouseLeave"
97
+ >
98
+ <TechnologyCard
99
+ :name="item.name"
100
+ :active="activeIndex === index"
101
+ className="w-72 h-72"
102
+ :index="index"
103
+ />
104
+ </div>
105
+ </div>
106
+ <Carousel
107
+ v-model="activeIndex"
108
+ v-bind="config"
109
+ class="-mx-4 xl:mx-0 xl:hidden"
110
+ >
111
+ <Slide v-for="(item, index) in technologies" :key="index">
112
+ <div class="flex items-center w-[190px] h-[220px]">
113
+ <TechnologyCard
114
+ :name="item.name"
115
+ :active="activeIndex === index"
116
+ :className="`w-full h-full ${activeIndex === index ? 'max-h-full' : 'max-h-[190px]'}`"
117
+ />
118
+ </div>
119
+ </Slide>
120
+ </Carousel>
121
+ </section>
122
+ </template>
@@ -0,0 +1,50 @@
1
+ <script setup lang="ts">
2
+ import { Marked, type RendererObject } from "marked";
3
+ import LazyImage from "../common/images/LazyImage.vue"
4
+ import { sanitize } from "isomorphic-dompurify";
5
+
6
+ /* See ArticlePreview.vue: the fields read here, not the generated Strapi
7
+ * `Feature`, which a caller may still pass unchanged. */
8
+ type PainPoint = {
9
+ content: unknown
10
+ image: { url: string; name: string }
11
+ }
12
+
13
+ const props = defineProps<{
14
+ item: PainPoint
15
+ /* Injected by the site; omit it when the URLs are already absolute. */
16
+ resolveImage?: (url: string) => string
17
+ }>()
18
+
19
+ const imageUrl = (url: string) => props.resolveImage?.(url) ?? url
20
+
21
+ const marked = new Marked()
22
+ const renderer: RendererObject = {
23
+ paragraph({ tokens }) {
24
+ return `<p class="text-lg">${this.parser.parseInline(tokens)}</p>`;
25
+ },
26
+ list(token) {
27
+ const body = token.items.map(item => `<li>${this.parser.parse(item.tokens)}</li>`).join('');
28
+
29
+ const type = token.ordered ? 'ol' : 'ul';
30
+ return `<${type} class="pt-2 pl-5 list-disc">${body}</${type}>`;
31
+ }
32
+ }
33
+ marked.use({ renderer })
34
+ const contentString = (typeof props.item.content === 'string' ? props.item.content : '') || ''
35
+ const htmlContent = sanitize(marked.parse(contentString, { async: false }))
36
+ </script>
37
+
38
+ <template>
39
+ <div class="mx-1 md:mx-0 p-6 w-56 h-full md:h-auto rounded-3xl bg-surface-secondary space-y-6">
40
+ <LazyImage
41
+ :src="imageUrl(item.image.url)"
42
+ :alt="item.image.name"
43
+ class="w-8 h-8"
44
+ />
45
+ <div
46
+ v-html="htmlContent"
47
+ class="text-body-secondary-lighter"
48
+ />
49
+ </div>
50
+ </template>
@@ -0,0 +1,5 @@
1
+ export const BREAKPOINTS = {
2
+ md: 768,
3
+ lg: 1024,
4
+ xl: 1280,
5
+ }
@@ -0,0 +1,10 @@
1
+ export const formattedDate = (locale: string | null, date: Date | null) => {
2
+ if (!locale || !date) return '';
3
+
4
+ const newDate = new Date(date)
5
+ return new Intl.DateTimeFormat(locale, {
6
+ year: 'numeric',
7
+ month: 'long',
8
+ day: 'numeric'
9
+ }).format(newDate)
10
+ }
package/dist/theme.css ADDED
@@ -0,0 +1,81 @@
1
+ /* ==========================================================================
2
+ * CODECAVE Tailwind theme — @codecavepro/brand/theme.css
3
+ * --------------------------------------------------------------------------
4
+ * GENERATED from docs/source_examples/styles/global.css — do not edit.
5
+ *
6
+ * Declares the names Tailwind needs in order to emit CODECAVE utility
7
+ * classes: bg-surface-primary, text-heading-lg, rounded-card and the rest.
8
+ * Without it the components in this package mount and behave correctly and
9
+ * render nearly unstyled.
10
+ *
11
+ * IMPORT ORDER MATTERS, and this file is wrong without it:
12
+ *
13
+ * @import "tailwindcss";
14
+ * @import "@codecavepro/brand/tokens.css"; <- must come first,
15
+ * @import "@codecavepro/brand/theme.css"; and stay unlayered
16
+ *
17
+ * Several entries here are deliberate self-references (--x: var(--x)). They
18
+ * exist so Tailwind emits the utility; the value comes from tokens.css,
19
+ * whose unlayered :root outranks @layer theme. Import tokens.css inside a
20
+ * cascade layer, or not at all, and those names resolve to nothing.
21
+ * ======================================================================== */
22
+
23
+ @theme {
24
+
25
+ /* sizes */
26
+ --max-width-desktop: 1280px;
27
+ --breakpoint-sm: 457px;
28
+ --font-sans: Satoshi, sans-serif;
29
+ --text-heading-lg: 3.5rem;
30
+ --text-heading-lg--line-height: 130%;
31
+ --text-heading-md: 2.75rem;
32
+ --text-heading-md--line-height: 115%;
33
+ --text-heading-sm: 2rem;
34
+ --text-heading-sm--line-height: 110%;
35
+ --radius-custom: 2.75rem;
36
+
37
+ /* colors */
38
+
39
+ /* primary */
40
+ --color-primary-25: var(--color-brand-25);
41
+ --color-primary-50: var(--color-brand-50);
42
+ --color-primary-100: var(--color-brand-100);
43
+ --color-primary-200: var(--color-brand-200);
44
+ --color-primary-300: var(--color-brand-300);
45
+ --color-primary-400: var(--color-brand-400);
46
+ --color-primary-500: var(--color-brand-500); /* button primary*/
47
+ --color-primary-600: var(--color-brand-600);
48
+ --color-primary-700: var(--color-brand-700); /* button primary hover*/
49
+ --color-primary-800: var(--color-brand-800);
50
+ --color-primary-900: var(--color-brand-900); /* button primary active*/
51
+
52
+
53
+ /* once used? */
54
+ --color-glow-25: var(--color-glow-25);
55
+ --color-progress-0: var(--color-progress-0);
56
+ --color-technology-gradient-0: var(--color-technology-gradient-0);
57
+ --color-technology-gradient-25: var(--color-technology-gradient-25);
58
+ --color-technology-gradient-50: var(--color-technology-gradient-50);
59
+ --color-shadow-0: var(--color-shadow-0);
60
+ --color-failureproof-0: var(--color-gray-1000);
61
+
62
+ --color-heading: var(--color-gray-50);
63
+ --color-action: var(--color-brand-500);
64
+ --color-neutral: var(--color-gray-600);
65
+ --color-hovered: var(--color-brand-200);
66
+ --color-outline-primary-hover: var(--color-brand-800);
67
+ --color-body-primary: var(--color-gray-50);
68
+ --color-text-body-primary: var(--color-gray-200);
69
+ --color-body-secondary: var(--color-gray-500);
70
+ --color-body-secondary-lighter: var(--color-gray-300);
71
+ --color-surface-primary: var(--color-brand-950);
72
+ --color-surface-primary-hover: var(--color-gray-800);
73
+ --color-surface-secondary: var(--color-gray-1100); /* Surface/background-secondary*/
74
+ --color-surface-tertiary: var(--color-gray-900);
75
+ --color-surface-primary-transparent: hsl(from var(--color-surface-primary) h s l / 0.3);
76
+ --color-surface-quaternary: var(--color-gray-700);
77
+
78
+ --color-default-transparent: var(--color-gray-100);
79
+
80
+ --color-error: var(--color-error-300);
81
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codecavepro/brand",
3
- "version": "1.2.0",
3
+ "version": "1.4.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",
@@ -26,6 +26,7 @@
26
26
  },
27
27
  "./css": "./dist/colors_and_type.css",
28
28
  "./tokens.css": "./dist/tokens.css",
29
+ "./theme.css": "./dist/theme.css",
29
30
  "./fonts.css": "./dist/fonts.css",
30
31
  "./components/*": "./dist/src/components/*",
31
32
  "./assets/*": "./dist/src/assets/*",
@@ -38,11 +39,23 @@
38
39
  ],
39
40
  "peerDependencies": {
40
41
  "gsap": "^3.13.0",
41
- "vue": "^3.5.22"
42
+ "isomorphic-dompurify": "^3.22.0",
43
+ "marked": "^18.0.5",
44
+ "vue": "^3.5.22",
45
+ "vue3-carousel": "^0.17.0"
42
46
  },
43
47
  "peerDependenciesMeta": {
44
48
  "gsap": {
45
49
  "optional": true
50
+ },
51
+ "isomorphic-dompurify": {
52
+ "optional": true
53
+ },
54
+ "marked": {
55
+ "optional": true
56
+ },
57
+ "vue3-carousel": {
58
+ "optional": true
46
59
  }
47
60
  },
48
61
  "scripts": {