@sit-onyx/storybook-utils 1.0.0-beta.4 → 1.0.0-beta.41

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sit-onyx/storybook-utils",
3
3
  "description": "Storybook utilities for Vue",
4
- "version": "1.0.0-beta.4",
4
+ "version": "1.0.0-beta.41",
5
5
  "type": "module",
6
6
  "author": "Schwarz IT KG",
7
7
  "license": "Apache-2.0",
@@ -23,17 +23,14 @@
23
23
  "url": "https://github.com/SchwarzIT/onyx/issues"
24
24
  },
25
25
  "peerDependencies": {
26
- "@storybook/core-events": ">= 8.0.0",
27
- "@storybook/docs-tools": ">= 8.0.0",
28
- "@storybook/preview-api": ">= 8.0.0",
29
- "@storybook/theming": ">= 8.0.0",
30
- "@storybook/vue3": ">= 8.0.0",
26
+ "@storybook/vue3": ">= 8.2.0",
27
+ "storybook": ">= 8.2.0",
31
28
  "storybook-dark-mode": ">= 4",
32
- "@sit-onyx/icons": "^1.0.0-beta.0",
33
- "sit-onyx": "^1.0.0-beta.4"
29
+ "@sit-onyx/icons": "^1.0.0-beta.1",
30
+ "sit-onyx": "^1.0.0-beta.39"
34
31
  },
35
32
  "dependencies": {
36
- "deepmerge-ts": "^7.0.3"
33
+ "deepmerge-ts": "^7.1.0"
37
34
  },
38
35
  "scripts": {
39
36
  "build": "tsc --noEmit",
package/src/actions.ts CHANGED
@@ -1,85 +1,61 @@
1
- import { useArgs } from "@storybook/preview-api";
2
- import type { ArgTypes, Decorator, Meta } from "@storybook/vue3";
3
- import { deepmerge } from "deepmerge-ts";
1
+ import type { Decorator } from "@storybook/vue3";
2
+ import { useArgs } from "storybook/internal/preview-api";
3
+ import type { ArgTypesEnhancer, StrictInputType } from "storybook/internal/types";
4
4
  import { isReactive, reactive, watch } from "vue";
5
- import type { DefineStorybookActionsAndVModelsOptions, ExtractVueEventNames } from ".";
6
5
 
7
6
  /**
8
- * Utility to define Storybook meta for a given Vue component which will take care of defining argTypes for
9
- * the given events as well as implementing v-model handlers so that the Storybook controls are updated when you interact with the component.
10
- * Should be preferred over manually defining argTypes for *.stories.ts files.
11
- *
12
- * @example
13
- * ```ts
14
- * // Input.stories.ts
15
- * import { defineStorybookActionsAndVModels } from '@sit-onyx/storybook-utils';
16
- * import type { Meta } from '@storybook/vue3';
17
- * import Input from './Input.vue';
18
- *
19
- * const meta: Meta<typeof Input> = {
20
- * title: 'components/Input',
21
- * ...defineStorybookActionsAndVModels({
22
- * component: Input,
23
- * events: ['update:modelValue', 'change'],
24
- * }),
25
- * };
26
- * ```
7
+ * Adds actions for all argTypes of the 'event' category, so that they are logged via the actions plugin.
27
8
  */
28
- export const defineStorybookActionsAndVModels = <T>(
29
- options: DefineStorybookActionsAndVModelsOptions<T>,
30
- ): Meta => {
31
- const defaultMeta = {
32
- argTypes: {
33
- ...defineActions(options.events),
34
- ...{}, // this is needed to fix a type issue
35
- },
36
- decorators: [withVModelDecorator(options.events)],
37
- } satisfies Meta;
38
-
39
- return deepmerge(options, defaultMeta);
9
+ export const enhanceEventArgTypes: ArgTypesEnhancer = ({ argTypes }) => {
10
+ Object.values(argTypes)
11
+ .filter(({ table }) => table?.category === "events")
12
+ .forEach(({ name }) => {
13
+ const eventName = `on${capitalizeFirstLetter(name)}`;
14
+ if (eventName in argTypes) {
15
+ return;
16
+ }
17
+ argTypes[eventName] = {
18
+ name: eventName,
19
+ table: { disable: true },
20
+ action: eventName,
21
+ };
22
+ });
23
+ return argTypes;
40
24
  };
41
25
 
42
- /**
43
- * Defines Storybook actions ("argTypes") for the given events.
44
- * Reason for this wrapper function is that Storybook expects event names to be prefixed
45
- * with "on", e.g. "onClick".
46
- *
47
- * However in Vue, the event names are plain like "click" instead of "onClick" because
48
- * otherwise we would use it like "@on-click="..."" which is redundant.
49
- *
50
- * So this utility will remove the on[eventName] entry from the Storybook panel/table
51
- * and register the correct eventName as action so it is logged in the "Actions" tab.
52
- *
53
- * @example defineActions(["click", "input"])
54
- */
55
- export const defineActions = <T>(events: ExtractVueEventNames<T>[]): ArgTypes => {
56
- return events.reduce<ArgTypes>((argTypes, eventName) => {
57
- argTypes[`on${capitalizeFirstLetter(eventName)}`] = {
58
- table: { disable: true },
59
- action: eventName,
60
- };
61
-
62
- argTypes[eventName] = { control: false };
63
- return argTypes;
64
- }, {});
26
+ export type WithVModelDecoratorOptions = {
27
+ /**
28
+ * The matcher for the v-model events.
29
+ * @default /^update:/
30
+ */
31
+ filter: (argType: StrictInputType) => boolean;
65
32
  };
66
33
 
67
34
  /**
68
- * Defines a custom decorator that will implement event handlers for all v-models
69
- * so that the Storybook controls are updated live when the user interacts with the component
35
+ * Defines a custom decorator that will implement event handlers for all v-models,
36
+ * so that the Storybook controls are updated live when the user interacts with the component.
37
+ * This ensures that the story and component props stay in sync.
70
38
  *
71
39
  * @example
72
40
  * ```ts
73
- * import Input from './Input.vue';
41
+ * // .storybook/preview.ts
74
42
  *
75
43
  * {
76
- * decorators: [withVModelDecorator<typeof Input>(["update:modelValue"])]
44
+ * decorators: [withVModelDecorator()]
77
45
  * }
78
46
  * ```
79
47
  */
80
- export const withVModelDecorator = <T>(events: ExtractVueEventNames<T>[]): Decorator => {
48
+
49
+ export const withVModelDecorator = (options?: WithVModelDecoratorOptions): Decorator => {
81
50
  return (story, ctx) => {
82
- const vModelEvents = events.filter((event) => event.startsWith("update:"));
51
+ const vModelFilter =
52
+ options?.filter ||
53
+ (({ table, name }) => table?.category === "events" && name.startsWith("update:"));
54
+
55
+ const vModelEvents = Object.values(ctx.argTypes)
56
+ .filter(vModelFilter)
57
+ .map(({ name }) => name);
58
+
83
59
  if (!vModelEvents.length) return story();
84
60
 
85
61
  const [args, updateArgs] = useArgs();
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./actions";
2
2
  export * from "./preview";
3
+ export * from "./sbType";
3
4
  export * from "./theme";
4
5
  export * from "./types";
package/src/preview.ts CHANGED
@@ -1,11 +1,11 @@
1
- import { DOCS_RENDERED } from "@storybook/core-events";
2
- import { addons } from "@storybook/preview-api";
3
- import { type ThemeVars } from "@storybook/theming";
1
+ import { getIconImportName } from "@sit-onyx/icons";
4
2
  import { type Preview, type StoryContext } from "@storybook/vue3";
5
3
  import { deepmerge } from "deepmerge-ts";
6
4
  import { DARK_MODE_EVENT_NAME } from "storybook-dark-mode";
7
-
8
- import { getIconImportName } from "@sit-onyx/icons";
5
+ import { DOCS_RENDERED } from "storybook/internal/core-events";
6
+ import { addons } from "storybook/internal/preview-api";
7
+ import type { ThemeVars } from "storybook/internal/theming";
8
+ import { enhanceEventArgTypes } from "./actions";
9
9
  import { requiredGlobalType, withRequired } from "./required";
10
10
  import { generateSourceCode } from "./source-code-generator";
11
11
  import { ONYX_BREAKPOINTS, createTheme } from "./theme";
@@ -22,6 +22,7 @@ const themes = {
22
22
  * - Setup for dark mode (including docs page). Requires addon `storybook-dark-mode` to be enabled in .storybook/main.ts file
23
23
  * - Custom Storybook theme using onyx colors (light and dark mode)
24
24
  * - Configure viewports / breakpoints as defined by onyx
25
+ * - Logs Vue emits as Storybook events
25
26
  *
26
27
  * @param overrides Custom preview / overrides, will be deep merged with the default preview.
27
28
  *
@@ -42,6 +43,7 @@ const themes = {
42
43
  */
43
44
  export const createPreview = <T extends Preview = Preview>(overrides?: T) => {
44
45
  const defaultPreview = {
46
+ argTypesEnhancers: [enhanceEventArgTypes],
45
47
  globalTypes: {
46
48
  ...requiredGlobalType,
47
49
  },
@@ -157,7 +159,10 @@ export const sourceCodeTransformer = (
157
159
  const escapedIconContent = `"${replaceAll(iconContent, '"', '\\"')}"`;
158
160
 
159
161
  if (code.includes(iconContent)) {
160
- code = code.replace(new RegExp(` (\\S+)=['"]${iconContent}['"]`), ` :$1="${importName}"`);
162
+ code = code.replace(
163
+ new RegExp(` (\\S+)=['"]${escapeRegExp(iconContent)}['"]`),
164
+ ` :$1="${importName}"`,
165
+ );
161
166
  additionalImports.push(`import ${importName} from "@sit-onyx/icons/${iconName}.svg?raw";`);
162
167
  } else if (code.includes(singleQuotedIconContent)) {
163
168
  // support icons inside objects
@@ -207,3 +212,11 @@ ${code}`;
207
212
  export const replaceAll = (value: string, searchValue: string | RegExp, replaceValue: string) => {
208
213
  return value.replace(new RegExp(searchValue, "gi"), replaceValue);
209
214
  };
215
+
216
+ /**
217
+ * Escapes the given string value to be used in `new RegExp()`.
218
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping
219
+ */
220
+ export const escapeRegExp = (string: string) => {
221
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
222
+ };
@@ -0,0 +1,38 @@
1
+ import type { SBType } from "storybook/internal/types";
2
+ import { describe, expect, test, vi } from "vitest";
3
+ import { walkTree } from "./sbType";
4
+
5
+ describe("walkTree", () => {
6
+ test.each<{ input: SBType; expected: SBType["name"][] }>([
7
+ { input: { name: "array", value: { name: "number" } }, expected: ["array", "number"] },
8
+ { input: { name: "object", value: { a: { name: "number" } } }, expected: ["object", "number"] },
9
+ { input: { name: "enum", value: ["a"] }, expected: ["enum"] },
10
+ {
11
+ input: { name: "intersection", value: [{ name: "number" }] },
12
+ expected: ["intersection", "number"],
13
+ },
14
+ { input: { name: "union", value: [{ name: "number" }] }, expected: ["union", "number"] },
15
+ { input: { name: "other", value: "a" }, expected: ["other"] },
16
+ ])("should execute cb for $input.name correctly", ({ input, expected }) => {
17
+ const spy = vi.fn<(p: SBType) => void>();
18
+ const result = walkTree(input, spy);
19
+
20
+ expect(result).toBeUndefined();
21
+ expect(spy).toHaveBeenCalledTimes(expected.length);
22
+ const nameCalls = spy.mock.calls.map(([{ name }]) => name);
23
+ expect(nameCalls).toMatchObject(expected);
24
+ });
25
+
26
+ test("should return value if there is any returned", () => {
27
+ const target: SBType = { name: "number", raw: "here" };
28
+ const overshoot: SBType = { name: "boolean", raw: "here" };
29
+ const parent: SBType = { name: "intersection", value: [target, overshoot] };
30
+ const returned = 42;
31
+ const spy = vi.fn((p: SBType) => (p.raw === "here" ? returned : undefined));
32
+ const result = walkTree({ name: "union", value: [parent] }, spy);
33
+
34
+ expect(spy).toHaveBeenCalledTimes(3);
35
+ expect(spy).toHaveBeenLastCalledWith(target, parent);
36
+ expect(result).toBe(returned);
37
+ });
38
+ });
package/src/sbType.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { SBType } from "storybook/internal/types";
2
+
3
+ /**
4
+ * Call a function `cb` for every type node in the storybook type tree.
5
+ * @param inputType the root type
6
+ * @param cb the function that is called for every type. If any non-nullish value is returned by `cb` the execution is stopped and this value is returned.
7
+ * @param parent optional, the parent type. Is only used as input for the `cb` function and provided when recursing.
8
+ * @returns the first non-nullish value that is returned by `cb`
9
+ */
10
+ export const walkTree = <TValue>(
11
+ inputType: SBType,
12
+ cb: (sb: SBType, parent?: SBType) => TValue,
13
+ parent?: SBType,
14
+ ): TValue | undefined => {
15
+ const shouldReturn = cb(inputType, parent);
16
+ if (shouldReturn) {
17
+ return shouldReturn;
18
+ }
19
+
20
+ if (inputType.name === "union" || inputType.name === "intersection") {
21
+ return inputType.value.reduce<TValue | undefined>(
22
+ (prev, it) => prev ?? walkTree(it, cb, inputType),
23
+ undefined,
24
+ );
25
+ }
26
+ if (inputType.name === "array") {
27
+ return walkTree(inputType.value, cb, inputType);
28
+ }
29
+ if (inputType.name === "object") {
30
+ return Object.values(inputType.value).reduce<TValue | undefined>(
31
+ (prev, it) => prev ?? walkTree(it, cb, inputType),
32
+ undefined,
33
+ );
34
+ }
35
+ };
@@ -156,16 +156,20 @@ test("should generate source code for slots with bindings", () => {
156
156
  type TestBindings = {
157
157
  foo: string;
158
158
  bar?: number;
159
+ boo: {
160
+ mimeType: string;
161
+ };
159
162
  };
160
163
 
161
164
  const slots = {
162
- a: ({ foo, bar }: TestBindings) => `Slot with bindings ${foo} and ${bar}`,
163
- b: ({ foo }: TestBindings) => h("a", { href: foo, target: foo }, `Test link: ${foo}`),
165
+ a: ({ foo, bar, boo }: TestBindings) => `Slot with bindings ${foo}, ${bar} and ${boo.mimeType}`,
166
+ b: ({ foo, boo }: TestBindings) =>
167
+ h("a", { href: foo, target: foo, type: boo.mimeType, ...boo }, `Test link: ${foo}`),
164
168
  };
165
169
 
166
- const expectedCode = `<template #a="{ foo, bar }">Slot with bindings {{ foo }} and {{ bar }}</template>
170
+ const expectedCode = `<template #a="{ foo, bar, boo }">Slot with bindings {{ foo }}, {{ bar }} and {{ boo.mimeType }}</template>
167
171
 
168
- <template #b="{ foo }"><a :href="foo" :target="foo">Test link: {{ foo }}</a></template>`;
172
+ <template #b="{ foo, boo }"><a :href="foo" :target="foo" :type="boo.mimeType" v-bind="boo">Test link: {{ foo }}</a></template>`;
169
173
 
170
174
  const actualCode = generateSlotSourceCode(slots, Object.keys(slots), {
171
175
  imports: {},
@@ -3,11 +3,25 @@
3
3
  // It is intended to be deleted once its officially released in Storybook itself, see:
4
4
  // https://github.com/storybookjs/storybook/pull/27194
5
5
  //
6
- import { SourceType } from "@storybook/docs-tools";
7
6
  import type { Args, StoryContext } from "@storybook/vue3";
7
+ import { SourceType } from "storybook/internal/docs-tools";
8
8
  import { isVNode, type VNode } from "vue";
9
9
  import { replaceAll } from "./preview";
10
10
 
11
+ /**
12
+ * Used to get the tracking data from the proxy.
13
+ * A symbol is unique, so when using it as a key it can't be accidentally accessed.
14
+ */
15
+ const TRACKING_SYMBOL = Symbol("DEEP_ACCESS_SYMBOL");
16
+
17
+ type TrackingProxy = {
18
+ [TRACKING_SYMBOL]: true;
19
+ toString: () => string;
20
+ };
21
+
22
+ const isProxy = (obj: unknown): obj is TrackingProxy =>
23
+ !!(obj && typeof obj === "object" && TRACKING_SYMBOL in obj);
24
+
11
25
  /**
12
26
  * Context that is passed down to nested components/slots when generating the source code for a single story.
13
27
  */
@@ -184,6 +198,10 @@ export const generatePropsSourceCode = (
184
198
  if (slotNames.includes(propName)) return;
185
199
  if (value == undefined) return; // do not render undefined/null values
186
200
 
201
+ if (isProxy(value)) {
202
+ value = value!.toString();
203
+ }
204
+
187
205
  switch (typeof value) {
188
206
  case "string":
189
207
  if (value === "") return; // do not render empty strings
@@ -225,7 +243,7 @@ export const generatePropsSourceCode = (
225
243
  case "object": {
226
244
  properties.push({
227
245
  name: propName,
228
- value: formatObject(value),
246
+ value: formatObject(value ?? {}),
229
247
  // to follow Vue best practices, complex values like object and arrays are
230
248
  // usually placed inside the <script setup> block instead of inlining them in the <template>
231
249
  templateFn: undefined,
@@ -373,25 +391,60 @@ const generateSlotChildrenSourceCode = (
373
391
  (param) => !["{", "}"].includes(param),
374
392
  );
375
393
 
376
- const parameters = paramNames.reduce<Record<string, string>>((obj, param) => {
377
- obj[param] = `{{ ${param} }}`;
378
- return obj;
379
- }, {});
380
-
381
- const returnValue = child(parameters);
382
- let slotSourceCode = generateSlotChildrenSourceCode([returnValue], ctx);
383
-
384
- // if slot bindings are used for properties of other components, our {{ paramName }} is incorrect because
385
- // it would generate e.g. my-prop="{{ paramName }}", therefore, we replace it here to e.g. :my-prop="paramName"
394
+ // We create proxy to track how and which properties of a parameter are accessed
395
+ const parameters: Record<string, string> = {};
396
+ const proxied: Record<string, TrackingProxy> = {};
386
397
  paramNames.forEach((param) => {
387
- slotSourceCode = replaceAll(
388
- slotSourceCode,
389
- new RegExp(` (\\S+)="{{ ${param} }}"`, "g"),
390
- ` :$1="${param}"`,
398
+ parameters[param] = `{{ ${param} }}`;
399
+ // TODO: we should be able to extend the proxy logic here and maybe get rid of the `generatePropsSourceCode` code
400
+ proxied[param] = new Proxy(
401
+ {
402
+ // we use the symbol to identify the proxy
403
+ [TRACKING_SYMBOL]: true,
404
+ } as TrackingProxy,
405
+ {
406
+ // getter is called when any prop of the parameter is read
407
+ get: (t, key) => {
408
+ if (key === TRACKING_SYMBOL) {
409
+ // allow retrieval of the tracking data
410
+ return t[TRACKING_SYMBOL];
411
+ }
412
+ if ([Symbol.toPrimitive, Symbol.toStringTag, "toString"].includes(key)) {
413
+ // when the parameter is used as a string we return the parameter name
414
+ // we use the double brace notation as we don't know if the parameter is used in text or in a binding
415
+ return () => `{{ ${param} }}`;
416
+ }
417
+ if (key === "v-bind") {
418
+ // if this key is returned we just return the parameter name
419
+ return `${param}`;
420
+ }
421
+ // otherwise a specific key of the parameter was accessed
422
+ // we use the double brace notation as we don't know if the parameter is used in text or in a binding
423
+ return `{{ ${param}.${key.toString()} }}`;
424
+ },
425
+ // ownKeys is called, among other uses, when an object is destructured
426
+ // in this case we assume the parameter is supposed to be bound using "v-bind"
427
+ // Therefore we only return one special key "v-bind" and the getter will be called afterwards with it
428
+ ownKeys: () => {
429
+ return [`v-bind`];
430
+ },
431
+ /** called when destructured */
432
+ getOwnPropertyDescriptor: () => ({
433
+ configurable: true,
434
+ enumerable: true,
435
+ value: param,
436
+ writable: true,
437
+ }),
438
+ },
391
439
  );
392
440
  });
393
441
 
394
- return slotSourceCode;
442
+ const returnValue = child(proxied);
443
+ const slotSourceCode = generateSlotChildrenSourceCode([returnValue], ctx);
444
+
445
+ // if slot bindings are used for properties of other components, our {{ paramName }} is incorrect because
446
+ // it would generate e.g. my-prop="{{ paramName }}", therefore, we replace it here to e.g. :my-prop="paramName"
447
+ return replaceAll(slotSourceCode, / (\S+)="{{ (\S+) }}"/g, ` :$1="$2"`);
395
448
  }
396
449
 
397
450
  case "bigint":
package/src/theme.ts CHANGED
@@ -1,7 +1,6 @@
1
- import type { ThemeVars, ThemeVarsPartial } from "@storybook/theming";
2
- import { create } from "@storybook/theming/create";
1
+ import { ONYX_BREAKPOINTS as RAW_ONYX_BREAKPOINTS, type OnyxBreakpoint } from "sit-onyx";
3
2
  import onyxVariables from "sit-onyx/themes/onyx.json";
4
- import { ONYX_BREAKPOINTS as RAW_ONYX_BREAKPOINTS, type OnyxBreakpoint } from "sit-onyx/types";
3
+ import { create, type ThemeVars, type ThemeVarsPartial } from "storybook/internal/theming";
5
4
  import onyxLogo from "./assets/logo-onyx.svg";
6
5
 
7
6
  /**
package/src/types.ts CHANGED
@@ -1,52 +1,3 @@
1
- import type { ComponentPropsAndSlots, Meta } from "@storybook/vue3";
2
-
3
- /**
4
- * Extracts all event names defined by e.g. `defineEmits()` from the given Vue component.
5
- *
6
- * @example
7
- * ```ts
8
- * import Input from "./Input.vue";
9
- * type InputEvents = ExtractVueEventNames<typeof Input>; // e.g. "input" | "change"
10
- * ```
11
- */
12
- export type ExtractVueEventNames<VueComponent> =
13
- Extract<
14
- // extract all props/events of the vue component that are functions
15
- ExtractKeysByValueType<
16
- // this generic type will extract ALL props and events from the given Vue component
17
- ComponentPropsAndSlots<VueComponent>,
18
- // emits are declared as functions, so we only take props/events that are functions and ignore the rest
19
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- We must use any here to match the type defined by Vue
20
- ((...args: any) => any) | undefined
21
- >,
22
- // filter out potential function properties by just picking events that start with "on"
23
- `on${string}`
24
- > extends `on${infer EventName}`
25
- ? // until now the extracted event names still start with "on" but we want to have the plain event name
26
- // so we will remove the "on" prefix and uncapitalized the first letter so e.g. "onClick" becomes "click"
27
- Uncapitalize<EventName>
28
- : never;
29
-
30
- /**
31
- * Extracts only the keys from T whose value type satisfies U.
32
- *
33
- * @example
34
- * ```ts
35
- * type Test = ExtractKeysByValueType<{ a: boolean, b: number, c: boolean }, boolean>
36
- * // result: "a" | "c"
37
- * ```
38
- */
39
- export type ExtractKeysByValueType<T, U> = { [P in keyof T]: T[P] extends U ? P : never }[keyof T] &
40
- keyof T;
41
-
42
- /**
43
- * Options for defining Storybook actions and v-models.
44
- */
45
- export type DefineStorybookActionsAndVModelsOptions<T> = Meta<T> & {
46
- component: NonNullable<T>;
47
- events: ExtractVueEventNames<T>[];
48
- };
49
-
50
1
  export type StorybookGlobalType<TValue> = {
51
2
  name: string;
52
3
  description: string;
@@ -55,5 +6,6 @@ export type StorybookGlobalType<TValue> = {
55
6
  icon: string;
56
7
  items: { value: TValue; right?: string; title: string }[];
57
8
  title?: string;
9
+ dynamicTitle?: boolean;
58
10
  };
59
11
  };