-
Notifications
You must be signed in to change notification settings - Fork 1
/
plopfile.ts
277 lines (241 loc) · 12 KB
/
plopfile.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
/* Copyright (c) Fortanix, Inc.
|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { dedent } from 'ts-dedent';
import * as path from 'node:path';
import { type HelperOptions } from 'handlebars';
import { type NodePlopAPI } from 'plop';
const componentTemplate = {
/*
Explanation:
- `import { ... } from '../util/componentUtil.ts'`
> `componentUtil.ts` has a few utilities to work with component definitions:
- Custom version of `classNames` that uses `classnames/dedupe` so that consumers can override/remove classes:
`<MyComponent className={{ 'predefined-class': false }}/> // Removes .predefined-class`
- To make this work, components should put the `className` at the *end* of `cx(..., className)`.
- `ComponentProps` type: automatically overrides the `className` prop to support `classnames` syntax.
- `import cl from './MyComponent.module.scss'`
> Imports the local class names from the given CSS/Sass module.
> To make this work with TypeScript, we need:
- `src/types/globals.d.ts` to make TypeScript aware of `.module.{s}css` files.
- `typescript-plugin-css-modules` compiler extension for more precise types + autocomplete support.
- `React.PropsWithChildren<...>`
> Add this if you want the component to support a standard `children: ReactNode` prop. If the component does
not allow children, or if you want to customize the type of `children`, remove this type wrapper.
- `/** ...`
> Add doc blocks to the component as well as individual properties, these will be shown in the Storybook docs.
- `variant?: undefined | 'x' | 'y',`
> For any optional (i.e. `?:`) prop, also specify `undefined` as a value explicitly. Since we have
`exactOptionalPropertyTypes` enabled in TS, this is necessary to allow `prop={undefined}` to work.
> This also requires `shouldRemoveUndefinedFromOptional` enabled in Storybook, for better controls UI.
- `ComponentProps<'div'> & ...`
> Add `ComponentProps<>` to the props if you want the consumer to be able to pass additional properties through
to the underlying element. Can be a tag (e.g. `'div'`) or another component (e.g. `typeof OtherComponent`).
- ```
role="presentation"
{...propsRest}
className={cx('bk', 'my-classname', className)
```
> If you added `ComponentProps<>`, then `propsRest` capture these additional props. Most props should be
defined *before* `{...propsRest}` so that the consumer can override props. If you don’t want a prop to be
able to be overridden, specify it after `{...propsRest}`. If you need to combine a prop from `propsRest` with
your own definition (e.g. as in `className` above), also add it after `{...propsRest}`.
> Add the global `bk` class name to any component to mark it as a Baklava component. This is useful for style
isolation, so that anything inside a `.bk` is excluded from other style rules. In the future once `@scope` is
supported in browsers it can be useful to do `@scope (.my-class) to (.bk) {}`.
*/
'Component.tsx': '\n' + dedent`
/* Copyright (c) Fortanix, Inc.
|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import * as React from 'react';
import { classNames as cx, type ComponentProps } from '{{{relative-path "src/util/componentUtil.ts"}}}';
import cl from './{{{component-name}}}.module.scss';
export { cl as {{{component-name}}}ClassNames };
export type {{{component-name}}}Props = React.PropsWithChildren<ComponentProps<{{{format-element-type-ts element-type}}}> & {
/** Whether this component should be unstyled. */
unstyled?: undefined | boolean,
/** Some property specific to \`{{{component-name}}}\` */
variant?: undefined | 'x' | 'y',
}>;
{{"\\n"~}}
{{~#if component-description}}
/**
* {{{component-description}}}
*/
{{/if}}
export const {{{component-name}}} = (props: {{{component-name}}}Props) => {
const { unstyled = false, variant, ...propsRest } = props;
return (
<{{{element-type}}}
role="presentation"
{...propsRest}
className={cx({
bk: true,
[cl['bk-{{{kebabCase component-name}}}']]: !unstyled,
[cl['bk-{{{kebabCase component-name}}}--x']]: variant === 'x',
[cl['bk-{{{kebabCase component-name}}}--y']]: variant === 'y',
}, propsRest.className)}
/>
);
};
` + '\n',
/*
Explanation:
- This is a CCS Modules file, plus Sass as a preprocessor.
> CSS Modules allows the following syntax: `:local()`, `:global()` for local and global classes respectively.
> Note: other CSS Modules syntax like `@value` or `composes` is not well-supported by Vite/lightningcss.
- `@use '../styling/defs.scss' as bk`
> Common definitions like mixins are loaded from `defs.scss` through Sass `@use`. Use `bk` as a namespace.
- `@layer baklava.components`
> All Baklava components should be in the `baklava.components` cascade layer, so that application CSS rules can
take precedence.
- `bk.$color-neutral-700`
> Design tokens that are static (e.g. not theme-dependent) can be defined using Sass variables. Dynamic
variables should use CSS custom properties (`--foo`).
- `--bk-my-component-background-color: light-dark(color1, color2)`
> This is an example of a semantic design token. They should be defined local to the component code.
> The CSS `light-dark()` function should be used to give both the light and dark theme colors in one place.
- `@media (prefers-reduced-motion: no-preference) { ...`
> For accessibility, most animations and transitions should only be enabled if the user agent is configured
with `prefers-reduced-motion: no-preference`. There are exceptions, e.g. if the animation has a functional
purpose (like for a loading spinner).
*/
'Component.module.scss': '\n' + dedent`
/* Copyright (c) Fortanix, Inc.
|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
@use '{{{relative-path "src/styling/defs.scss"}}}' as bk;
@layer baklava.components {
.bk-{{{kebabCase component-name}}} {
@include bk.component-base(bk-{{{kebabCase component-name}}});
--bk-{{{kebabCase component-name}}}-background-color: light-dark(#{bk.$color-neutral-700}, #{bk.$color-neutral-50});
--bk-{{{kebabCase component-name}}}-text-color: light-dark(#{bk.$color-neutral-50}, #{bk.$color-neutral-700});
cursor: default;
overflow-y: auto;
max-width: 30rem;
max-height: 8lh;
margin: bk.$sizing-s;
padding: bk.$sizing-s;
border-radius: bk.$sizing-s;
background: var(--bk-{{{kebabCase component-name}}}-background-color);
@include bk.text-layout;
color: var(--bk-{{{kebabCase component-name}}}-text-color);
@include bk.font(bk.$font-family-body);
font-size: bk.$font-size-m;
@media (prefers-reduced-motion: no-preference) {
--transition-duration: 150ms;
transition:
opacity var(--transition-duration) ease-out;
}
}
}
` + '\n',
/*
Explanation:
- `export const Standard: Story = ...`
> The first exported story in the module will be used as the "standard" story at the top of the generated docs
page. This story should give the most typical example of the component.
*/
'Component.stories.tsx': '\n' + dedent`
/* Copyright (c) Fortanix, Inc.
|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import type { Meta, StoryObj } from '@storybook/react';
import * as React from 'react';
import { {{{component-name}}} } from './{{{component-name}}}.tsx';
type {{{component-name}}}Args = React.ComponentProps<typeof {{{component-name}}}>;
type Story = StoryObj<{{{component-name}}}Args>;
export default {
component: {{{component-name}}},
parameters: {
layout: '{{storybook-layout}}',
},
tags: ['autodocs'],
argTypes: {
},
args: {
children: 'Example',
},
render: (args) => <{{{component-name}}} {...args}/>,
} satisfies Meta<{{{component-name}}}Args>;
export const Standard: Story = {
name: '{{{component-name}}}',
};
` + '\n',
};
export default (plop: NodePlopAPI) => {
// Compute the relative path from the component file to the given target path.
plop.setHelper('relative-path', (pathTargetFromCwd, options: HelperOptions) => {
if (typeof pathTargetFromCwd !== 'string') { throw new Error(`Missing argument to 'relative-path' helper`); }
const pathComponentFromCwd: undefined | string = options.data?.root?.['component-path'];
if (typeof pathComponentFromCwd !== 'string') { throw new Error(`Missing 'component-path' variable`); }
const pathComponentAbs = path.join(process.cwd(), pathComponentFromCwd);
const pathTargetAbs = path.join(process.cwd(), pathTargetFromCwd);
const pathTargetRel = path.relative(pathComponentAbs, pathTargetAbs);
return pathTargetRel;
});
// Format the given component type as a TypeScript expression
plop.setHelper('format-element-type-ts', (componentType, options: HelperOptions) => {
if (typeof componentType !== 'string') { throw new Error(`Missing argument to 'format-element-type' helper`); }
if (/^[A-Z]/.test(componentType)) {
return `typeof ${componentType}`;
} else {
return `'${componentType}'`;
}
});
plop.setGenerator('component', {
description: 'Generating a Baklava component',
prompts: [
{
type: 'input',
name: 'component-path',
message: 'What is the path to the parent directory of the component? (E.g. `src/components/MyComponent`)',
default: 'src/components',
},
{
type: 'input',
name: 'component-name',
message: 'What is the name of the component (in CamelCase)? (E.g. `MyComponent`)',
default: (answers) => {
const directoryName = path.basename(answers['component-path']);
return directoryName ?? 'MyComponent';
},
},
{
type: 'input',
name: 'element-type',
message: 'What is the element type of the component? (E.g. "div", "OtherComponent")',
default: 'div',
},
{
type: 'input',
name: 'component-description',
message: 'Give a description for the component in sentence case (e.g. "A simple button component".)',
},
{
type: 'input',
name: 'storybook-layout',
message: 'Which Storybook layout to use? (E.g. fullscreen, padded, centered)',
default: 'centered',
},
],
actions: [
{
type: 'add',
path: '{{component-path}}/{{{component-name}}}.tsx',
template: componentTemplate['Component.tsx'],
},
{
type: 'add',
path: '{{component-path}}/{{{component-name}}}.module.scss',
template: componentTemplate['Component.module.scss'],
},
{
type: 'add',
path: '{{component-path}}/{{{component-name}}}.stories.tsx',
template: componentTemplate['Component.stories.tsx'],
},
],
});
};