@port60/template-kit 0.20.4 → 1.0.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.
Files changed (58) hide show
  1. package/README.md +42 -3
  2. package/bin/cli.mjs +10 -0
  3. package/package.json +4 -2
  4. package/src/commands/content.mjs +4 -3
  5. package/src/commands/create.mjs +11 -3
  6. package/src/commands/dev.mjs +9 -3
  7. package/src/commands/model.mjs +3 -3
  8. package/src/commands/packageCmd.mjs +1 -1
  9. package/src/commands/publish.mjs +1 -1
  10. package/src/commands/refresh.mjs +8 -3
  11. package/src/commands/release.mjs +22 -0
  12. package/src/commands/setupPreviews.mjs +19 -0
  13. package/src/commands/validate.mjs +1 -1
  14. package/src/lib/agentsMd.mjs +108 -155
  15. package/src/lib/designer-bridge.js +26 -0
  16. package/src/lib/designerPalette.mjs +62 -0
  17. package/src/lib/galleryPosters.mjs +105 -0
  18. package/src/lib/previewOptions.mjs +1 -1
  19. package/src/lib/releaseBundle.mjs +141 -0
  20. package/src/vendor/contract/v1/context.json +1 -1
  21. package/src/vendor/contract/v1/manifest.schema.json +6 -0
  22. package/src/vendor/contract/v2/behaviours.json +346 -0
  23. package/src/vendor/contract/v2/content-bounds.json +117 -0
  24. package/src/vendor/contract/v2/content-model.json +766 -0
  25. package/src/vendor/contract/v2/context.json +1569 -0
  26. package/src/vendor/contract/v2/dialect.json +105 -0
  27. package/src/vendor/contract/v2/fonts.json +910 -0
  28. package/src/vendor/contract/v2/imagery.json +32 -0
  29. package/src/vendor/contract/v2/islands.json +263 -0
  30. package/src/vendor/contract/v2/layout.json +28 -0
  31. package/src/vendor/contract/v2/manifest.schema.json +388 -0
  32. package/src/vendor/contract/v2/sections.json +947 -0
  33. package/src/vendor/contract/v2/site.schema.json +1353 -0
  34. package/src/vendor/contract/v2/tokens.json +101 -0
  35. package/src/vendor/contract/v2.lock.json +6931 -0
  36. package/src/vendor/engine/content-footprint.mjs +76 -1
  37. package/src/vendor/engine/majors.mjs +42 -0
  38. package/src/vendor/validator/gift-aid-logo.svg +6 -0
  39. package/src/vendor/validator/model-reference-v2.mjs +95 -0
  40. package/src/vendor/validator/platform-base.css +359 -2
  41. package/src/vendor/validator/preview-v1.mjs +691 -0
  42. package/src/vendor/validator/preview-v2.mjs +654 -0
  43. package/src/vendor/validator/preview.mjs +8 -688
  44. package/src/vendor/validator/site-context-v2.mjs +113 -0
  45. package/src/vendor/validator/validate-v1.mjs +666 -0
  46. package/src/vendor/validator/validate-v2.mjs +642 -0
  47. package/src/vendor/validator/validate.mjs +21 -621
  48. package/starter/assets/theme.css +3 -0
  49. package/starter/layout.liquid +15 -18
  50. package/starter/manifest.json +6 -4
  51. package/starter/preview/config.json +4 -0
  52. package/starter/preview/media/README.md +9 -0
  53. package/starter/sections/campaigns.liquid +5 -4
  54. package/starter/sections/cta.liquid +2 -2
  55. package/starter/sections/hero.liquid +9 -2
  56. package/starter/sections/homeHero.liquid +28 -24
  57. package/starter/sections/people.liquid +7 -6
  58. package/starter/sections/values.liquid +3 -3
@@ -0,0 +1,346 @@
1
+ {
2
+ "description": "The behaviour catalogue: engine-attached JavaScript a template opts into with data attributes on its own markup. Templates never ship code; they author complete static markup and declare behaviours in supports.behaviors. The engine adds the p60-js class to the root element when its script runs; any CSS that hides pre-behaviour content MUST be scoped under .p60-js so the no-JS render stays complete. All behaviours honour prefers-reduced-motion centrally. State classes are toggled by the engine; their appearance is entirely the template's CSS. Each entry's primaryAttribute is the opt-in attribute: the validator derives its declaration checks from it and the reference documents it, so the catalogue is the single source.",
3
+ "behaviours": [
4
+ {
5
+ "name": "reveal",
6
+ "status": "available",
7
+ "label": "Reveal on scroll",
8
+ "primaryAttribute": "data-p60-reveal",
9
+ "description": "Adds is-revealed when the element first enters the viewport; the template's CSS does the actual motion. data-p60-reveal-group on a container staggers its children by stamping --p60-reveal-index on each (use it in a transition-delay calc). Reduced motion: is-revealed applies immediately on load.",
10
+ "attributes": [
11
+ {
12
+ "attr": "data-p60-reveal",
13
+ "value": "optional variant name for the CSS to key on"
14
+ },
15
+ {
16
+ "attr": "data-p60-reveal-group",
17
+ "value": "none; staggers direct children"
18
+ }
19
+ ],
20
+ "stateClasses": [
21
+ "is-revealed"
22
+ ],
23
+ "cssVars": [
24
+ "--p60-reveal-index"
25
+ ]
26
+ },
27
+ {
28
+ "name": "counter",
29
+ "status": "available",
30
+ "label": "Animated counter",
31
+ "primaryAttribute": "data-p60-count",
32
+ "description": "Counts the element's figure up from zero when first revealed. The static text IS the final value, typed exactly as it should read (\"£20\", \"95%\", \"1,200\", \"2.5k\"): the engine animates only the first run of digits and keeps the prefix, suffix, grouping and decimal places as typed, restoring the exact text on the last frame. A numeric attribute value (legacy) overrides the target; the text still sets the format. Reduced motion: the final figure shows immediately.",
33
+ "attributes": [
34
+ {
35
+ "attr": "data-p60-count",
36
+ "value": "none: the element's own text is the target and the format; or a numeric target (legacy)"
37
+ },
38
+ {
39
+ "attr": "data-p60-count-duration",
40
+ "value": "optional ms, capped at 4000 (default 1200)"
41
+ }
42
+ ],
43
+ "stateClasses": [
44
+ "is-revealed"
45
+ ],
46
+ "cssVars": []
47
+ },
48
+ {
49
+ "name": "progress",
50
+ "status": "available",
51
+ "label": "Progress bar sweep",
52
+ "primaryAttribute": "data-p60-progress",
53
+ "description": "Animates the marked element's width from zero to its authored width when first revealed. The authored width (inline style or CSS) is the target and the no-JS render. Reduced motion: the authored width stands untouched.",
54
+ "attributes": [
55
+ {
56
+ "attr": "data-p60-progress",
57
+ "value": "none; the element's own width is the target"
58
+ }
59
+ ],
60
+ "stateClasses": [
61
+ "is-revealed"
62
+ ],
63
+ "cssVars": []
64
+ },
65
+ {
66
+ "name": "countdown",
67
+ "status": "available",
68
+ "label": "Countdown",
69
+ "primaryAttribute": "data-p60-countdown",
70
+ "description": "Replaces the element's content with a live compact remainder to data-p60-until (\"3d 12h 45m\"). The static content is the authored date text (the no-JS render). On expiry the engine adds is-elapsed and swaps to data-p60-elapsed-text when provided. Countdowns keep ticking under reduced motion: they are information, not decoration. v1 unit labels are compact and language-neutral (d/h/m/s).",
71
+ "attributes": [
72
+ {
73
+ "attr": "data-p60-countdown",
74
+ "value": "segments: dhms, dhm (default) or hm"
75
+ },
76
+ {
77
+ "attr": "data-p60-until",
78
+ "value": "ISO-8601 instant, required"
79
+ },
80
+ {
81
+ "attr": "data-p60-elapsed-text",
82
+ "value": "optional text shown once elapsed"
83
+ }
84
+ ],
85
+ "stateClasses": [
86
+ "is-elapsed"
87
+ ],
88
+ "cssVars": []
89
+ },
90
+ {
91
+ "name": "accordion",
92
+ "status": "available",
93
+ "label": "Accordion",
94
+ "primaryAttribute": "data-p60-accordion",
95
+ "description": "On a container of native details elements: exclusive-open (opening one closes the others) plus smooth height animation. data-p60-accordion=\"multi\" keeps independent opening. Built on details/summary so the no-JS render is already functional and semantics are free. Reduced motion: no height animation, instant toggle.",
96
+ "attributes": [
97
+ {
98
+ "attr": "data-p60-accordion",
99
+ "value": "none for exclusive-open; \"multi\" for independent"
100
+ }
101
+ ],
102
+ "stateClasses": [
103
+ "is-open"
104
+ ],
105
+ "cssVars": []
106
+ },
107
+ {
108
+ "name": "stickyHeader",
109
+ "status": "available",
110
+ "label": "Sticky header condense",
111
+ "primaryAttribute": "data-p60-sticky-header",
112
+ "description": "State classes for scroll-aware headers: past a small threshold the engine adds is-condensed; with the \"auto-hide\" value, scrolling down past 160px adds is-hidden and scrolling up removes it. The engine only ever toggles classes (rAF-throttled), what condensing or hiding looks like, and whether it animates, is entirely the template's CSS (gate transitions behind prefers-reduced-motion yourself; the classes always apply because a condensed header is layout, not decoration).",
113
+ "attributes": [
114
+ {
115
+ "attr": "data-p60-sticky-header",
116
+ "value": "none for condense-only; \"auto-hide\" adds the hide-on-scroll-down pair"
117
+ }
118
+ ],
119
+ "stateClasses": [
120
+ "is-condensed",
121
+ "is-hidden"
122
+ ],
123
+ "cssVars": []
124
+ },
125
+ {
126
+ "name": "stickyCta",
127
+ "status": "available",
128
+ "label": "Sticky call-to-action bar",
129
+ "primaryAttribute": "data-p60-sticky-cta",
130
+ "description": "Adds is-stuck once the visitor scrolls past the threshold (the attribute value in px, default 480) and removes it above. The bar should DUPLICATE an action that already exists in the page (a donate button, a ticket link); because of that, this is the one behaviour whose element may be hidden unconditionally in CSS rather than .p60-js-scoped: a no-JS visitor loses nothing; the original action is still on the page.",
131
+ "attributes": [
132
+ {
133
+ "attr": "data-p60-sticky-cta",
134
+ "value": "optional scroll threshold in px (default 480)"
135
+ }
136
+ ],
137
+ "stateClasses": [
138
+ "is-stuck"
139
+ ],
140
+ "cssVars": []
141
+ },
142
+ {
143
+ "name": "lightbox",
144
+ "status": "available",
145
+ "label": "Lightbox",
146
+ "primaryAttribute": "data-p60-lightbox",
147
+ "description": "On a group container: each data-p60-lightbox-item is an anchor whose href is the full image (the no-JS render simply navigates to it, already functional). The engine intercepts the click and opens an injected overlay (.p60-lightbox) with the image, a caption from data-p60-caption or the thumbnail's alt, a close button, backdrop and Escape close, arrow-key prev/next within the group, and a focus trap. Reduced motion: no transition on the overlay (the engine sets none; style the steady state).",
148
+ "attributes": [
149
+ {
150
+ "attr": "data-p60-lightbox",
151
+ "value": "none; the group container"
152
+ },
153
+ {
154
+ "attr": "data-p60-lightbox-item",
155
+ "value": "none; on each anchor to a full image"
156
+ },
157
+ {
158
+ "attr": "data-p60-caption",
159
+ "value": "optional caption text on an item"
160
+ }
161
+ ],
162
+ "stateClasses": [
163
+ "is-open"
164
+ ],
165
+ "cssVars": [],
166
+ "injects": [
167
+ ".p60-lightbox",
168
+ ".p60-lightbox-img",
169
+ ".p60-lightbox-caption",
170
+ ".p60-lightbox-close",
171
+ ".p60-lightbox-prev",
172
+ ".p60-lightbox-next"
173
+ ]
174
+ },
175
+ {
176
+ "name": "tabs",
177
+ "status": "available",
178
+ "label": "Tabs",
179
+ "primaryAttribute": "data-p60-tabs",
180
+ "description": "data-p60-tab=\"key\" on each tab control and data-p60-panel=\"key\" on each panel, inside a data-p60-tabs container. The engine wires the tablist/tab/tabpanel roles, aria-selected, roving tabindex, arrow/Home/End keys, toggles is-active on the active pair and hides inactive panels with the hidden attribute, so the no-JS render shows every panel stacked and complete, with no CSS scoping needed. The first tab (or the one authored with is-active) starts active.",
181
+ "attributes": [
182
+ {
183
+ "attr": "data-p60-tabs",
184
+ "value": "none; the container"
185
+ },
186
+ {
187
+ "attr": "data-p60-tab",
188
+ "value": "the key of the panel this control activates"
189
+ },
190
+ {
191
+ "attr": "data-p60-panel",
192
+ "value": "the key matching its control"
193
+ }
194
+ ],
195
+ "stateClasses": [
196
+ "is-active"
197
+ ],
198
+ "cssVars": []
199
+ },
200
+ {
201
+ "name": "carousel",
202
+ "status": "available",
203
+ "label": "Carousel",
204
+ "primaryAttribute": "data-p60-carousel",
205
+ "description": "On a container with one data-p60-slide per child: the engine manages is-active across slides, injects a dots nav (.p60-carousel-dots with one button per slide), wires optional template-provided data-p60-carousel-prev/data-p60-carousel-next controls, adds swipe, keyboard arrows, and hides inactive slides from assistive tech. Value \"auto\" enables auto-advance (data-p60-interval ms, min 3000, default 6000) and injects a rotation control (.p60-carousel-pause, first in the carousel's tab order) following the WAI-ARIA carousel pattern: hovering pauses and leaving resumes, keyboard focus entering the carousel stops rotation and leaving does not restart it, and the control is the only way back; is-paused sits on the carousel while rotation is stopped. Reduced motion disables auto-advance and the control while every other control keeps working. The engine stamps --p60-carousel-index on the container for track-translate designs; class-based fade on is-active is the simplest pattern. CSS hiding non-active slides MUST be .p60-js-scoped so the no-JS render shows all slides.",
206
+ "attributes": [
207
+ {
208
+ "attr": "data-p60-carousel",
209
+ "value": "none for manual; \"auto\" for auto-advance"
210
+ },
211
+ {
212
+ "attr": "data-p60-slide",
213
+ "value": "none; one per slide child"
214
+ },
215
+ {
216
+ "attr": "data-p60-interval",
217
+ "value": "optional auto-advance ms, min 3000 (default 6000)"
218
+ },
219
+ {
220
+ "attr": "data-p60-carousel-prev",
221
+ "value": "none; optional template-provided control"
222
+ },
223
+ {
224
+ "attr": "data-p60-carousel-next",
225
+ "value": "none; optional template-provided control"
226
+ }
227
+ ],
228
+ "stateClasses": [
229
+ "is-active",
230
+ "is-paused"
231
+ ],
232
+ "cssVars": [
233
+ "--p60-carousel-index"
234
+ ],
235
+ "injects": [
236
+ ".p60-carousel-dots",
237
+ ".p60-carousel-dot",
238
+ ".p60-carousel-dot--active",
239
+ ".p60-carousel-pause",
240
+ ".p60-carousel-pause--stopped"
241
+ ]
242
+ },
243
+ {
244
+ "name": "readingProgress",
245
+ "status": "available",
246
+ "label": "Reading progress",
247
+ "primaryAttribute": "data-p60-reading-progress",
248
+ "description": "A reading-position indicator. The engine writes --p60-reading-progress (0 to 1) on the marked element and keeps a progressbar role honest (aria-valuenow 0 to 100, an accessible name of \"Reading progress\" unless the template gives one); the bar's shape and motion are the template's CSS. With data-p60-reading-target naming an element's id the reading is that element's extent, otherwise the whole document; a target the page does not have leaves the bar static. is-started once past zero, is-complete at the end. Information, not decoration: it keeps updating under reduced motion. The bar is pure enhancement, so it may be hidden unconditionally in CSS: a no-JS visitor loses nothing.",
249
+ "attributes": [
250
+ {
251
+ "attr": "data-p60-reading-progress",
252
+ "value": "none; the indicator element"
253
+ },
254
+ {
255
+ "attr": "data-p60-reading-target",
256
+ "value": "optional id of the element whose extent is the reading (default: the document)"
257
+ }
258
+ ],
259
+ "stateClasses": [
260
+ "is-started",
261
+ "is-complete"
262
+ ],
263
+ "cssVars": [
264
+ "--p60-reading-progress"
265
+ ]
266
+ },
267
+ {
268
+ "name": "showMore",
269
+ "status": "available",
270
+ "label": "Show more and less",
271
+ "primaryAttribute": "data-p60-show-more",
272
+ "description": "A list that starts short. On a container whose entries carry data-p60-show-more-item and which holds (or is named by aria-controls from) a data-p60-show-more-toggle button: the engine hides every item past the visible count with the hidden attribute, so the no-JS render shows the complete list and needs no CSS scoping; the toggle expands and collapses, aria-expanded stays honest, is-expanded sits on the container, and a toggle whose attribute carries a value swaps its text to that value while expanded. A list with nothing to hide hides its toggle: there is never a dead control. Author the toggle to show only under .p60-js.",
273
+ "attributes": [
274
+ {
275
+ "attr": "data-p60-show-more",
276
+ "value": "optional number of items shown while collapsed (default 6)"
277
+ },
278
+ {
279
+ "attr": "data-p60-show-more-item",
280
+ "value": "none; on each entry"
281
+ },
282
+ {
283
+ "attr": "data-p60-show-more-toggle",
284
+ "value": "none, or the label to show while expanded; on a button inside the list or pointing at it with aria-controls"
285
+ }
286
+ ],
287
+ "stateClasses": [
288
+ "is-expanded"
289
+ ],
290
+ "cssVars": []
291
+ },
292
+ {
293
+ "name": "scrollspy",
294
+ "status": "available",
295
+ "label": "Scrollspy",
296
+ "primaryAttribute": "data-p60-scrollspy",
297
+ "description": "On a menu of in-page links (href=\"#id\"): the link whose section is being read gets is-current and aria-current=\"location\", where the current section is the target furthest down the page whose top has passed the activation line (the attribute value in px from the top of the viewport, default 96). Links keep navigating and nothing ever moves focus; links whose target is not on the page are ignored, and a menu with no targets is left alone. Information, not decoration: it keeps updating under reduced motion.",
298
+ "attributes": [
299
+ {
300
+ "attr": "data-p60-scrollspy",
301
+ "value": "none, or the activation line in px (default 96); on the menu"
302
+ }
303
+ ],
304
+ "stateClasses": [
305
+ "is-current"
306
+ ],
307
+ "cssVars": []
308
+ },
309
+ {
310
+ "name": "nav",
311
+ "status": "available",
312
+ "label": "Navigation",
313
+ "primaryAttribute": "data-p60-nav",
314
+ "description": "Your menu, made robust. You design the menu (markup, every pixel, the breakpoint); the engine supplies the interaction CSS :hover cannot: data-p60-nav on each nav root (several allowed), data-p60-nav-item on each group holding a data-p60-nav-toggle control and a data-p60-nav-menu panel (nesting allowed). Hover-capable pointers get hover intent (a short delay to open, a grace period to close) so crossing the gap between a toggle and its panel never closes it; keyboard, touch and pen toggle on the control (Enter/click, ArrowDown opens and focuses the first link; a mouse click on an already-hovered parent link navigates); Escape closes and returns focus to the toggle; a click outside or focus moving away closes; one item stays open per level and aria-expanded is kept honest. The burger: data-p60-nav-burger on a button whose aria-controls (or the attribute value) names the stacked panel; the engine toggles is-open on both, closes on Escape (returning focus), on a real navigation inside the panel, on an outside click, and when the burger disappears at a wider viewport. State is is-open on the item, the panel and the burger; under .p60-js show a panel only on is-open and keep your :hover/:focus-within rules scoped to html:not(.p60-js) for the no-JS render. Fit steps: when the root's row wraps or overflows, the engine steps is-fit-1, is-fit-2, is-fit-3 onto the root until it fits, re-measured on resize and once fonts settle, never on a stacked (column) nav; the platform's defaults shrink the member_menu pill at each step (the Port60 ID wordmark, then the text, leaving the person icon with its accessible label), and a template maps the same classes to anything else it wants to give up before the burger.",
315
+ "attributes": [
316
+ {
317
+ "attr": "data-p60-nav",
318
+ "value": "none; a nav root, several allowed"
319
+ },
320
+ {
321
+ "attr": "data-p60-nav-item",
322
+ "value": "none; a group holding one toggle and one menu, nestable"
323
+ },
324
+ {
325
+ "attr": "data-p60-nav-toggle",
326
+ "value": "none; the item's control (a link or button)"
327
+ },
328
+ {
329
+ "attr": "data-p60-nav-menu",
330
+ "value": "none; the item's panel"
331
+ },
332
+ {
333
+ "attr": "data-p60-nav-burger",
334
+ "value": "none, or the id of the panel when aria-controls is absent"
335
+ }
336
+ ],
337
+ "stateClasses": [
338
+ "is-open",
339
+ "is-fit-1",
340
+ "is-fit-2",
341
+ "is-fit-3"
342
+ ],
343
+ "cssVars": []
344
+ }
345
+ ]
346
+ }
@@ -0,0 +1,117 @@
1
+ {
2
+ "description": "The maximum lengths of every admin-entered display field a template renders. The admin screens stop input at these caps and the API rejects anything longer, so a template can design cards, headings and captions against known worst cases. Additive-only within a contract major: a bound may loosen, never tighten. Long-form bodies (article bodies, course About pages, service pages) are deliberately outside this table; they are sanitised HTML rendered in dedicated full-width regions, never inside cards.",
3
+ "groups": [
4
+ {
5
+ "name": "Brand (layout chrome)",
6
+ "fields": [
7
+ {
8
+ "field": "brand.name",
9
+ "max": 60,
10
+ "note": "The masthead/wordmark text."
11
+ },
12
+ {
13
+ "field": "brand.tagline",
14
+ "max": 140,
15
+ "note": "One line under the masthead; may be empty."
16
+ }
17
+ ]
18
+ },
19
+ {
20
+ "name": "Articles",
21
+ "fields": [
22
+ {
23
+ "field": "Article title",
24
+ "max": 200
25
+ },
26
+ {
27
+ "field": "Excerpt",
28
+ "max": 500,
29
+ "note": "The card/front-page summary."
30
+ },
31
+ {
32
+ "field": "Author name / role",
33
+ "max": 120,
34
+ "note": "Each."
35
+ },
36
+ {
37
+ "field": "Public collection label",
38
+ "max": 60,
39
+ "note": "Required shared name for articles, events, courses, services and campaigns. Placement headings may inherit, override or explicitly hide it."
40
+ },
41
+ {
42
+ "field": "Categories",
43
+ "max": null,
44
+ "note": "≤5 per article; names come from a tenant catalogue capped at 20 entries."
45
+ }
46
+ ]
47
+ },
48
+ {
49
+ "name": "Events",
50
+ "fields": [
51
+ {
52
+ "field": "Event name",
53
+ "max": 200
54
+ },
55
+ {
56
+ "field": "Summary (description)",
57
+ "max": 200,
58
+ "note": "A sentence or two for cards and listings. The long-form About body is separate sanitised HTML on the detail page."
59
+ },
60
+ {
61
+ "field": "Venue name",
62
+ "max": 200
63
+ },
64
+ {
65
+ "field": "Venue address",
66
+ "max": 500
67
+ },
68
+ {
69
+ "field": "Tag",
70
+ "max": 40,
71
+ "note": "Each, rendered as chips."
72
+ }
73
+ ]
74
+ },
75
+ {
76
+ "name": "Courses",
77
+ "fields": [
78
+ {
79
+ "field": "Course title",
80
+ "max": 200
81
+ },
82
+ {
83
+ "field": "Description",
84
+ "max": 500,
85
+ "note": "The card/detail summary; the long-form About page is separate sanitised HTML."
86
+ },
87
+ {
88
+ "field": "Venue label",
89
+ "max": 200
90
+ }
91
+ ]
92
+ },
93
+ {
94
+ "name": "Donations",
95
+ "fields": [
96
+ {
97
+ "field": "Cause name",
98
+ "max": 200
99
+ },
100
+ {
101
+ "field": "Cause description",
102
+ "max": 500
103
+ }
104
+ ]
105
+ },
106
+ {
107
+ "name": "Service pages",
108
+ "fields": [
109
+ {
110
+ "field": "Page name",
111
+ "max": 200,
112
+ "note": "The body is sanitised HTML rendered full-width, not card content."
113
+ }
114
+ ]
115
+ }
116
+ ]
117
+ }