lookbook 0.2.2 → 0.3.0.beta.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +93 -0
  3. data/app/assets/lookbook/css/app.css +28 -0
  4. data/app/assets/lookbook/js/app.js +49 -24
  5. data/app/assets/lookbook/js/nav/leaf.js +20 -0
  6. data/app/assets/lookbook/js/nav/node.js +31 -0
  7. data/app/assets/lookbook/js/nav.js +36 -0
  8. data/app/assets/lookbook/js/page.js +33 -0
  9. data/app/assets/lookbook/js/utils/clipboard.js +13 -0
  10. data/app/assets/lookbook/js/utils/morph.js +16 -0
  11. data/app/assets/lookbook/js/{reloader.js → utils/reloader.js} +0 -0
  12. data/app/assets/lookbook/js/utils/screen.js +44 -0
  13. data/app/assets/lookbook/js/{size_observer.js → utils/size_observer.js} +1 -1
  14. data/app/assets/lookbook/js/{split.js → utils/split.js} +4 -4
  15. data/app/assets/lookbook/js/workbench/inspector.js +11 -0
  16. data/app/assets/lookbook/js/workbench/preview.js +39 -0
  17. data/app/assets/lookbook/js/workbench.js +14 -0
  18. data/app/controllers/lookbook/{browser_controller.rb → app_controller.rb} +58 -31
  19. data/app/helpers/lookbook/application_helper.rb +1 -1
  20. data/app/views/lookbook/_sidebar.html.erb +45 -0
  21. data/app/views/lookbook/_workbench.html.erb +12 -0
  22. data/app/views/lookbook/{browser → app}/error.html.erb +0 -0
  23. data/app/views/lookbook/app/index.html.erb +11 -0
  24. data/app/views/lookbook/{browser → app}/not_found.html.erb +1 -1
  25. data/app/views/lookbook/app/show.html.erb +1 -0
  26. data/app/views/lookbook/layouts/app.html.erb +16 -26
  27. data/app/views/lookbook/layouts/group.html.erb +6 -0
  28. data/app/views/lookbook/nav/_collection.html.erb +5 -0
  29. data/app/views/lookbook/nav/_node.html.erb +19 -0
  30. data/app/views/lookbook/nav/_preview.html.erb +29 -0
  31. data/app/views/lookbook/shared/_clipboard.html.erb +11 -0
  32. data/app/views/lookbook/shared/_header.html.erb +8 -0
  33. data/app/views/lookbook/workbench/_header.html.erb +37 -0
  34. data/app/views/lookbook/workbench/_inspector.html.erb +32 -0
  35. data/app/views/lookbook/workbench/_preview.html.erb +24 -0
  36. data/app/views/lookbook/workbench/inspector/_code.html.erb +3 -0
  37. data/app/views/lookbook/workbench/inspector/_notes.html.erb +24 -0
  38. data/app/views/lookbook/{partials → workbench}/inspector/_plain.html.erb +0 -0
  39. data/config/routes.rb +3 -3
  40. data/lib/lookbook/engine.rb +2 -2
  41. data/lib/lookbook/preview.rb +25 -3
  42. data/lib/lookbook/preview_controller.rb +6 -1
  43. data/lib/lookbook/preview_example.rb +3 -2
  44. data/lib/lookbook/preview_group.rb +37 -0
  45. data/lib/lookbook/taggable.rb +5 -1
  46. data/lib/lookbook/version.rb +1 -1
  47. data/lib/lookbook.rb +1 -0
  48. data/lib/tasks/lookbook_tasks.rake +1 -1
  49. data/public/lookbook-assets/app.css +256 -102
  50. data/public/lookbook-assets/app.js +964 -95
  51. data/{app/views/lookbook/partials/_icon_sprite.html.erb → public/lookbook-assets/feather-sprite.svg} +1 -1
  52. metadata +52 -25
  53. data/app/assets/lookbook/js/preview.js +0 -76
  54. data/app/views/lookbook/browser/index.html.erb +0 -8
  55. data/app/views/lookbook/browser/show.html.erb +0 -33
  56. data/app/views/lookbook/partials/_preview.html.erb +0 -18
  57. data/app/views/lookbook/partials/_sidebar.html.erb +0 -21
  58. data/app/views/lookbook/partials/inspector/_code.html.erb +0 -1
  59. data/app/views/lookbook/partials/inspector/_inspector.html.erb +0 -43
  60. data/app/views/lookbook/partials/inspector/_prose.html.erb +0 -3
  61. data/app/views/lookbook/partials/nav/_collection.html.erb +0 -17
  62. data/app/views/lookbook/partials/nav/_label.html.erb +0 -13
  63. data/app/views/lookbook/partials/nav/_nav.html.erb +0 -27
  64. data/app/views/lookbook/partials/nav/_preview.html.erb +0 -48
  65. data/config/lookbook_cable.yml +0 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e8a93baa87f47304f68676fd5884b61eb32782f552e89316e4abb702373003f
4
- data.tar.gz: ada4f4b935bc63066b980a8faf7fcf81f4f7a594790f4f4ddd8a01a630b880f5
3
+ metadata.gz: 6b0d42f049a62be24079955f108c14f4b7b73b8b0f0a677639cce865e2260006
4
+ data.tar.gz: db6d156648495478cdcd55101065cb3875cfbdfb2b9bce3f924e37a11b2b789b
5
5
  SHA512:
6
- metadata.gz: 8cc694ae333a50382654e9c36f52a9cfe964a20bd6715ce365dd55adcca44c564e1a742c053625286dad9f929bf9640919d961277d4c426ab7c9584cb57fd5da
7
- data.tar.gz: ef0fc829bf1528cc521f2cd98887fc67476ed1d0a316e8ebefd9813e744714a0145b897091f7cc6768c2b459b1c3e5bf3982b2ee7ae451f2b2e512fa35e67b4a
6
+ metadata.gz: 15a7f9761f859bfdc9965801aba418e30fc95520635fc1a03dd9ff63af5e73e98563bc8d16420ae705824f18dffcdc9f834b57547815c4ab5cf899aba831e2e3
7
+ data.tar.gz: 889cea13089b40fa493c7ca39e6ff871f9db6432b9fffa1b57ab1a3a686b6e04ee2d0e3859093abcea386ccd67e2d7fe3aeaf2acf239632c535332d2a35e2ae3
data/README.md CHANGED
@@ -112,6 +112,29 @@ class ButtonComponentPreview < ViewComponent::Preview
112
112
  "Click me"
113
113
  end
114
114
  end
115
+
116
+ # @!group More examples
117
+
118
+ def short_text
119
+ render ButtonComponent.new do
120
+ "Go"
121
+ end
122
+ end
123
+
124
+ def long_text
125
+ render ButtonComponent.new do
126
+ "Click here to do this thing because it's the best way to do it"
127
+ end
128
+ end
129
+
130
+ def emoji_text
131
+ render ButtonComponent.new do
132
+ "👀📗"
133
+ end
134
+ end
135
+
136
+ # @!endgroup
137
+
115
138
  end
116
139
  ```
117
140
 
@@ -153,6 +176,52 @@ class FooComponentPreview < ViewComponent::Preview
153
176
  end
154
177
  ```
155
178
 
179
+ #### `@!group <name> ... @!endgroup`
180
+
181
+ For smaller components, it can often make sense to render a set of preview examples in a single window, rather than representing them as individual items in the navigation which can start to look a bit cluttered.
182
+
183
+ You can group a set of examples by wrapping them in `@!group <name>` / `@!endgroup` tags within your preview file:
184
+
185
+ ```ruby
186
+ class HeaderComponentPreview < ViewComponent::Preview
187
+
188
+ def standard
189
+ render Elements::HeaderComponent.new do
190
+ "Standard header"
191
+ end
192
+ end
193
+
194
+ # @!group Sizes
195
+
196
+ def small
197
+ render Elements::HeaderComponent.new(size: 12) do
198
+ "Small header"
199
+ end
200
+ end
201
+
202
+ def medium
203
+ render Elements::HeaderComponent.new(size: 16) do
204
+ "Small header"
205
+ end
206
+ end
207
+
208
+ def big
209
+ render Elements::HeaderComponent.new(size: 24) do
210
+ "Small header"
211
+ end
212
+ end
213
+
214
+ # @!endgroup
215
+
216
+ end
217
+ ```
218
+
219
+ The example above would display the `Sizes` examples grouped together on a single page, rather than as indiviual items in the navigation:
220
+
221
+ <img src=".github/assets/nav_group.png">
222
+
223
+ You can have as many groups as you like within a single preview class, but each example can only belong to one group.
224
+
156
225
  #### Adding notes
157
226
 
158
227
  All comment text other than tags will be treated as markdown and rendered in the **Notes** panel for that example in the Lookbook UI.
@@ -193,6 +262,30 @@ If you wish to add additional paths to listen for changes in, you can use the `l
193
262
  config.lookbook.listen_paths << Rails.root.join('app/other/directory')
194
263
  ```
195
264
 
265
+ ## Keyboard shortcuts
266
+
267
+ Lookbook provides a few keyboard shortcuts to help you quickly move around the UI.
268
+
269
+ - `f` - move focus to the nav filter box
270
+ - `Esc` [when focus is in nav filter box] - Clear contents if text is present, or return focus to the UI if the box is already empty
271
+ - `s` - Switch to Source tab in the inspector
272
+ - `o` - Switch to Output tab in the inspector
273
+ - `n` - Switch to Notes tab in the inspector
274
+ - `r` - Refresh the preview (useful if using something like Faker to generate randomised data for the preview)
275
+ - `w` - Open the standalone rendered preview in a new window
276
+
277
+ ## Troubleshooting
278
+
279
+ #### Blank preview window
280
+
281
+ Certain setups (for example when using `Rack::LiveReload`) can cause an issue with the way that the preview iframe displays the rendered component preview (i.e. using the `srcdoc` attribute to avoid extra requests).
282
+
283
+ If you are seeing a blank preview window, but the source and output tabs are both displaying code as expected, you can disable the use of the `srcdoc` attribute using the following configuration option:
284
+
285
+ ```ruby
286
+ config.lookbook.preview_srcdoc = false
287
+ ```
288
+
196
289
  ## Contributing
197
290
 
198
291
  Lookbook is very much a small hobby/side project at the moment. I'd love to hear from anyone who is interested in contributing but I'm terrible at replying to emails or messages, so don't be surprised if I take forever to get back to you. It's not personal 😜
@@ -34,4 +34,32 @@
34
34
  stroke-linejoin: round;
35
35
  fill: none;
36
36
  }
37
+
38
+ .h-fill {
39
+ height: fill-available;
40
+ }
41
+
42
+ .min-h-fill {
43
+ min-height: fill-available;
44
+ }
45
+
46
+ ::-webkit-scrollbar {
47
+ width: 8px;
48
+ height: 8px;
49
+ }
50
+
51
+ ::-webkit-scrollbar-track {
52
+ background: transparent;
53
+ }
54
+
55
+ ::-webkit-scrollbar-thumb {
56
+ @apply bg-gray-300 transition-colors;
57
+ border-radius: 6px;
58
+ border: 2px solid transparent;
59
+ background-clip: content-box;
60
+ }
61
+
62
+ ::-webkit-scrollbar-thumb:hover {
63
+ @apply bg-gray-400;
64
+ }
37
65
  }
@@ -1,49 +1,74 @@
1
+ import { install } from "@github/hotkey";
1
2
  import Alpine from "alpinejs";
2
3
  import Fern from "@ryangjchandler/fern";
3
- import Tooltip from "@ryangjchandler/alpine-tooltip";
4
- import Clipboard from "@ryangjchandler/alpine-clipboard";
5
- import split from "./split";
6
- import preview from "./preview";
7
- import observeSize from "./size_observer";
8
- import reloader from "./reloader";
4
+ import AlpineTooltip from "@ryangjchandler/alpine-tooltip";
5
+ import AlpineClipboard from "@ryangjchandler/alpine-clipboard";
6
+ import Screen from "./utils/screen";
7
+ import split from "./utils/split";
8
+ import page from "./page";
9
+ import workbench from "./workbench";
10
+ import preview from "./workbench/preview";
11
+ import inspector from "./workbench/inspector";
12
+ import nav from "./nav";
13
+ import navNode from "./nav/node";
14
+ import navLeaf from "./nav/leaf";
15
+ import sizeObserver from "./utils/size_observer";
16
+ import reloader from "./utils/reloader";
17
+ import clipboard from "./utils/clipboard";
18
+
19
+ window.Alpine = Alpine;
9
20
 
10
21
  // Plugins
11
22
 
12
23
  Alpine.plugin(Fern);
13
- Alpine.plugin(Tooltip);
14
- Alpine.plugin(Clipboard);
15
-
16
- // Data
17
-
18
- Alpine.data("preview", preview);
19
- Alpine.data("sizeObserver", observeSize);
20
- Alpine.data("split", split);
24
+ Alpine.plugin(AlpineTooltip);
25
+ Alpine.plugin(AlpineClipboard);
26
+ Alpine.plugin(Screen);
21
27
 
22
28
  // Stores
23
29
 
24
- Alpine.store("app", { reflowing: false });
30
+ Alpine.store("page", {
31
+ reflowing: false,
32
+ doc: window.document,
33
+ });
34
+
25
35
  Alpine.persistedStore("nav", {
26
36
  width: 280,
27
37
  filter: "",
28
38
  open: {},
29
- scrollTop: 0,
30
- shouldDisplay(previewName) {
31
- const cleanFilter = this.filter.replace(/\s/g, "");
32
- return (
33
- cleanFilter === "" || previewName.includes(cleanFilter.toLowerCase())
34
- );
35
- },
36
39
  });
37
- Alpine.persistedStore("preview", {});
40
+
38
41
  Alpine.persistedStore("inspector", {
39
42
  height: 200,
40
43
  active: "source",
41
44
  });
42
45
 
46
+ Alpine.persistedStore("preview", {
47
+ width: "100%",
48
+ });
49
+
50
+ // Components & utils
51
+
52
+ Alpine.data("page", page);
53
+ Alpine.data("nav", nav);
54
+ Alpine.data("navNode", navNode);
55
+ Alpine.data("navLeaf", navLeaf);
56
+ Alpine.data("workbench", workbench);
57
+ Alpine.data("preview", preview);
58
+ Alpine.data("inspector", inspector);
59
+ Alpine.data("clipboard", clipboard);
60
+ Alpine.data("sizeObserver", sizeObserver);
61
+ Alpine.data("split", split);
62
+
43
63
  // Init
44
64
 
45
- window.Alpine = Alpine;
65
+ for (const el of document.querySelectorAll("[data-hotkey]")) {
66
+ install(el);
67
+ }
68
+
46
69
  if (window.SOCKET_PATH) {
47
70
  reloader(window.SOCKET_PATH).start();
48
71
  }
72
+
73
+ window.Alpine = Alpine;
49
74
  Alpine.start();
@@ -0,0 +1,20 @@
1
+ export default function navLeaf() {
2
+ return {
3
+ path: null,
4
+ matchers: [],
5
+ active: false,
6
+ hidden: false,
7
+ setActive() {
8
+ this.active = this.path === window.location.pathname;
9
+ },
10
+ filter() {
11
+ if (this.$store.nav.filtering) {
12
+ const text = this.$store.nav.filterText;
13
+ const matched = this.matchers.map((m) => m.includes(text));
14
+ this.hidden = !matched.filter((m) => m).length;
15
+ } else {
16
+ this.hidden = false;
17
+ }
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,31 @@
1
+ export default function navNode() {
2
+ return {
3
+ id: null,
4
+ hidden: true,
5
+ children: [],
6
+ init() {
7
+ this.id = this.$el.id;
8
+ },
9
+ open() {
10
+ return this.$store.nav.open[this.id];
11
+ },
12
+ getChildren() {
13
+ return this.$refs.items
14
+ ? Array.from(this.$refs.items.querySelectorAll(":scope > li"))
15
+ : [];
16
+ },
17
+ filter() {
18
+ this.hidden = true;
19
+ this.getChildren().forEach((child) => {
20
+ const data = child._x_dataStack[0];
21
+ data.filter();
22
+ if (!data.hidden) {
23
+ this.hidden = false;
24
+ }
25
+ });
26
+ },
27
+ toggle() {
28
+ this.$store.nav.open[this.id] = !this.$store.nav.open[this.id];
29
+ },
30
+ };
31
+ }
@@ -0,0 +1,36 @@
1
+ import morph from "./utils/morph";
2
+
3
+ export default function () {
4
+ return {
5
+ clearFilter() {
6
+ this.$store.nav.filter = "";
7
+ },
8
+ init() {
9
+ this.$watch("$store.nav.filter", (value) => {
10
+ const nav = this.$store.nav;
11
+ nav.filterText = value.replace(/\s/g, "").toLowerCase();
12
+ nav.filtering = nav.filterText.length > 0;
13
+ });
14
+ },
15
+ updateNav(event) {
16
+ const nav = document.getElementById("nav");
17
+ nav.style.height = `${this.$refs.shim.offsetHeight}px`;
18
+ morph(nav, event.detail.doc.getElementById("nav"));
19
+ Promise.resolve().then(() => {
20
+ this.$refs.shim.style.height = "auto";
21
+ this.$dispatch("nav:updated");
22
+ });
23
+ },
24
+ navigate($event) {
25
+ history.pushState({}, null, $event.currentTarget.href);
26
+ this.$dispatch("popstate");
27
+ },
28
+ focusFilter() {
29
+ this.currentFocus = this.$refs.filter;
30
+ setTimeout(() => this.$refs.filter.focus(), 0);
31
+ },
32
+ unfocusFilter() {
33
+ this.$refs.filter.blur();
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,33 @@
1
+ import morph from "./utils/morph";
2
+
3
+ export default function page() {
4
+ const store = Alpine.store("page");
5
+ return {
6
+ ready: false,
7
+ sidebarOpenMobile: false,
8
+ init() {
9
+ this.$nextTick(() => (this.ready = true));
10
+ },
11
+ splitProps: {
12
+ minSize: 200,
13
+ onDrag(splits) {
14
+ Alpine.store("nav").width = Math.min(splits[0], 500);
15
+ },
16
+ },
17
+ async fetchHTML() {
18
+ const response = await fetch(window.document.location);
19
+ if (!response.ok) return window.location.reload();
20
+ const html = await response.text();
21
+ store.doc = new DOMParser().parseFromString(html, "text/html");
22
+ return store.doc;
23
+ },
24
+ updateTitle() {
25
+ document.title = store.doc.title;
26
+ },
27
+ render() {
28
+ if (this.ready) {
29
+ morph(this.$el, store.doc.getElementById(this.$el.id));
30
+ }
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,13 @@
1
+ export default function clipboard() {
2
+ return {
3
+ content: null,
4
+ done: false,
5
+ save() {
6
+ this.$clipboard(this.content);
7
+ this.done = true;
8
+ setTimeout(() => {
9
+ this.done = false;
10
+ }, 1000);
11
+ },
12
+ };
13
+ }
@@ -0,0 +1,16 @@
1
+ import morph from "morphdom";
2
+
3
+ export default function (from, to, opts = {}) {
4
+ morph(from, to, {
5
+ onBeforeElUpdated: function (fromEl, toEl) {
6
+ if (fromEl._x_dataStack) {
7
+ Alpine.clone(fromEl, toEl);
8
+ }
9
+ if (fromEl.isEqualNode(toEl)) {
10
+ return false;
11
+ }
12
+ return true;
13
+ },
14
+ ...opts,
15
+ });
16
+ }
@@ -0,0 +1,44 @@
1
+ // Adapted from: https://github.com/alpine-collective/toolkit
2
+
3
+ export default function (Alpine) {
4
+ // Create reactive data context
5
+ let data = Alpine.reactive({ screensize: window.innerWidth });
6
+
7
+ // Configuration
8
+ const defaultBreakpoints = {
9
+ xs: 0,
10
+ sm: 640,
11
+ md: 768,
12
+ lg: 1024,
13
+ xl: 1280,
14
+ "2xl": 1536,
15
+ };
16
+
17
+ const breakpoints =
18
+ window.AlpineMagicHelpersConfig &&
19
+ window.AlpineMagicHelpersConfig.breakpoints
20
+ ? window.AlpineMagicHelpersConfig.breakpoints
21
+ : defaultBreakpoints;
22
+
23
+ window.addEventListener("resize", () => {
24
+ data.screensize = window.innerWidth;
25
+ });
26
+
27
+ Alpine.magic("screen", () => (breakpoint) => {
28
+ let width = data.screensize;
29
+
30
+ if (Number.isInteger(breakpoint)) return breakpoint <= width;
31
+
32
+ // Check if breakpoint exists
33
+ if (breakpoints[breakpoint] === undefined) {
34
+ throw Error(
35
+ "Undefined $screen property: " +
36
+ breakpoint +
37
+ ". Supported properties: " +
38
+ Object.keys(breakpoints).join(", ")
39
+ );
40
+ }
41
+
42
+ return breakpoints[breakpoint] <= width;
43
+ });
44
+ }
@@ -1,4 +1,4 @@
1
- export default function () {
1
+ export default function sizeObserver() {
2
2
  return {
3
3
  observedWidth: 0,
4
4
  observedHeight: 0,
@@ -1,7 +1,7 @@
1
1
  import Split from "split-grid";
2
2
 
3
3
  export default function (props) {
4
- const app = Alpine.store("app");
4
+ const page = Alpine.store("page");
5
5
  return {
6
6
  init() {
7
7
  Split({
@@ -11,14 +11,14 @@ export default function (props) {
11
11
  minSize: props.minSize,
12
12
  writeStyle() {},
13
13
  onDrag(dir, track, style) {
14
- splits = style.split(" ").map((num) => parseInt(num));
14
+ const splits = style.split(" ").map((num) => parseInt(num));
15
15
  props.onDrag(splits);
16
16
  },
17
17
  onDragStart() {
18
- app.reflowing = true;
18
+ page.reflowing = true;
19
19
  },
20
20
  onDragEnd() {
21
- app.reflowing = false;
21
+ page.reflowing = false;
22
22
  },
23
23
  });
24
24
  },
@@ -0,0 +1,11 @@
1
+ export default function inspector() {
2
+ const inspector = Alpine.store("inspector");
3
+ return {
4
+ switchTo(id) {
5
+ inspector.active = id;
6
+ },
7
+ active(id) {
8
+ return inspector.active === id;
9
+ },
10
+ };
11
+ }
@@ -0,0 +1,39 @@
1
+ export default function preview() {
2
+ const app = Alpine.store("page");
3
+ const preview = Alpine.store("preview");
4
+ return {
5
+ init() {
6
+ this.root = this.$el;
7
+ },
8
+ onResize(e) {
9
+ const size =
10
+ this.resizeStartSize - (this.resizeStartPosition - e.pageX) * 2;
11
+ const parentSize = this.root.parentElement.clientWidth;
12
+ const percentSize = (Math.round(size) / parentSize) * 100;
13
+ const minWidth = (300 / parentSize) * 100;
14
+ preview.width = `${Math.min(Math.max(percentSize, minWidth), 100)}%`;
15
+ },
16
+ onResizeStart(e) {
17
+ app.reflowing = true;
18
+ this.onResize = this.onResize.bind(this);
19
+ this.onResizeEnd = this.onResizeEnd.bind(this);
20
+ this.resizeStartPosition = e.pageX;
21
+ this.resizeStartSize = this.root.clientWidth;
22
+ window.addEventListener("pointermove", this.onResize);
23
+ window.addEventListener("pointerup", this.onResizeEnd);
24
+ },
25
+ onResizeEnd() {
26
+ window.removeEventListener("pointermove", this.onResize);
27
+ window.removeEventListener("pointerup", this.onResizeEnd);
28
+ app.reflowing = false;
29
+ },
30
+ toggleFullWidth() {
31
+ if (preview.width === "100%" && preview.lastWidth) {
32
+ preview.width = preview.lastWidth;
33
+ } else {
34
+ preview.lastWidth = preview.width;
35
+ preview.width = "100%";
36
+ }
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,14 @@
1
+ export default function workbench() {
2
+ const inspector = Alpine.store("inspector");
3
+ return {
4
+ previewViewportHeight: 0,
5
+ previewViewportWidth: 0,
6
+ splitProps: {
7
+ direction: "vertical",
8
+ minSize: 200,
9
+ onDrag(splits) {
10
+ inspector.height = splits[2];
11
+ },
12
+ },
13
+ };
14
+ }