@uselab/vue-islands 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.
package/README.md ADDED
@@ -0,0 +1,295 @@
1
+ # vue-islands
2
+
3
+ Progressively mount Vue components ("islands") into server-rendered semantic HTML, using
4
+ `data-component` attributes to declare which elements should become Vue components.
5
+
6
+ Components can be used as Vue islands as well as in the normal Vue way, so you can mix and match server-rendered HTML with client-side.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @uselab/vue-islands vue
12
+ ```
13
+
14
+ `vue` (`^3.0.0`) is a peer dependency, so use whichever Vue 3 version your project already depends
15
+ on. `zod` (`^4.0.0`) is also an optional peer dependency, only required if you use
16
+ `validateRawProps`:
17
+
18
+ ```bash
19
+ npm install zod
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ Mark up your server-rendered HTML with `data-component` (and optionally `data-props`)
25
+ attributes:
26
+
27
+ ```html
28
+ <div data-component="my-widget" data-title="Hello" data-count-json="3"></div>
29
+ ```
30
+
31
+ Then, in your TypeScript entry point, register the matching Vue components and call
32
+ `initiateVueIslands` once the DOM is ready:
33
+
34
+ ```ts
35
+ import {
36
+ initiateVueIslands,
37
+ createVNodeFunction,
38
+ type GetVNode,
39
+ } from '@uselab/vue-islands';
40
+ import MyWidget from './MyWidget.vue';
41
+
42
+ const components: Record<string, GetVNode> = {
43
+ 'my-widget': createVNodeFunction(MyWidget),
44
+ };
45
+
46
+ initiateVueIslands(components, {
47
+ // Optional: enable dev-only console logging of which islands get mounted.
48
+ isDevMode: import.meta.env.DEV,
49
+ });
50
+ ```
51
+
52
+ Component names can be written in `camelCase` in the `components` map (as above); `data-component`
53
+ attributes in the HTML are matched case-insensitively against the `kebab-case` form of these keys,
54
+ so `menu`, `heavyChart`, and `imageCarousel` above match `data-component="menu"`,
55
+ `data-component="heavy-chart"`, and `data-component="image-carousel"` respectively.
56
+
57
+ Each matching element is replaced (or, with `data-keep-semantic-html`, appended into) with a
58
+ mounted Vue app rendering the corresponding component, using `attributes`/`data-props` as props and
59
+ child elements as slots.
60
+
61
+ ## Declaring components and props in HTML
62
+
63
+ - `data-component="my-widget"` — marks an element for mounting; the value is matched
64
+ case-insensitively against the `kebab-case` form of the key in your `components` map.
65
+ - `data-component-tag-name="div"` — overrides the tag name of the mounted root element (e.g.
66
+ `<ol data-component="my-widget" data-component-tag-name="div">` mounts into a `<div>` instead of
67
+ an `<ol>`). When omitted, the tag name of the original element is used.
68
+ - `data-keep-semantic-html` — when truthy (`""`, `"true"`, or `"1"`), the original server-rendered
69
+ element is kept in the DOM and the mounted component is appended into it, instead of replacing
70
+ it. Useful when the original element must stay in place for SEO, accessibility, or CSS reasons
71
+ (e.g. an `<a>` that should keep behaving like a link until the island takes over).
72
+
73
+ Props can be defined in several ways:
74
+
75
+ - As plain attributes: `<div data-component="my-widget" title="Boo">`
76
+ - As `data-{name}` attributes: `<div data-component="my-widget" data-title="Boo">`
77
+ - As attributes ending in `-json` to pass parsed JSON data: `<div data-component="my-widget"
78
+ data-options-json='{"key":"value"}' data-show-json="true" data-counter-json="12">`
79
+ - In `data-props`, as a query string mapping `propName=attributeName`: `<a
80
+ data-component="my-widget" href="/boo" data-props="link-url=href">` sets the `link-url` prop to
81
+ the element's `href` value.
82
+ - In `data-props`, using `text-content` to read the element's text: `<div
83
+ data-component="my-widget" data-props="title=text-content">Boo</div>`
84
+ - In `data-props`, JSON parsing applies when either side of the mapping ends in `-json`: `<div
85
+ data-component="my-widget" data-items-json="[1,2,3]" data-props="items=data-items-json">` and
86
+ `<div data-component="my-widget" data-props="items-json=text-content">[1,2,3]</div>` both parse
87
+ the value as JSON.
88
+ - On any descendant element that has `data-props` but **not** `data-component` — useful for
89
+ composing a prop object from several child elements without introducing extra slot markup.
90
+
91
+ Prop names in `data-props` support dot and bracket notation to build nested objects and arrays:
92
+
93
+ ```html
94
+ <div data-component="my-widget">
95
+ <h2 data-props="title=text-content">Boo</h2>
96
+ <img data-props="image.src=src&amp;image.alt=alt" src="/image.jpg" alt="An image" />
97
+ <div data-props="cities[0]=text-content">Amsterdam</div>
98
+ <div data-props="cities[1]=text-content">Rotterdam</div>
99
+ </div>
100
+ ```
101
+
102
+ Descendant elements that **don't** have `data-props` (and aren't themselves `data-component`
103
+ islands) are passed through as slots instead, grouped by their `slot` or `data-slot` attribute:
104
+
105
+ ```html
106
+ <div data-component="my-widget">
107
+ <div slot="header">Boo</div>
108
+ <div slot="footer">Baa</div>
109
+ </div>
110
+ ```
111
+
112
+ A descendant element with its own `data-component` attribute is rendered as a normal Vue component
113
+ within the parent island, so slot content can include other registered components:
114
+
115
+ ```html
116
+ <div data-component="my-widget">
117
+ <div slot="header">
118
+ <span data-component="user-badge" data-name="Boo"></span>
119
+ </div>
120
+ </div>
121
+ ```
122
+
123
+ These nested components are **not** separate vue-islands — only the root `[data-component]`
124
+ elements queried by `initiateVueIslands` are individually mounted as their own `App` instance and
125
+ replace (or append into) their host element. A `data-component` found while walking a parent
126
+ island's slot content is turned into a plain Vue vnode instead, rendered as part of that parent
127
+ app. As a result, `data-keep-semantic-html` (and the replace-vs-append behavior it controls) only
128
+ has an effect on root elements; setting it on a nested component has no effect.
129
+
130
+ ## Configuring each mounted app
131
+
132
+ Every island is its own, independent Vue `App` instance — each `data-component` element gets its
133
+ own `createApp(...)`/`mount(...)` call under the hood. Use the `configureApp` option to run setup
134
+ logic against every one of these instances before it's mounted, e.g. to register an i18n plugin so
135
+ translations are available inside every island, or a shared Pinia instance so islands can read from
136
+ and write to the same store:
137
+
138
+ ```ts
139
+ import { createPinia } from 'pinia';
140
+ import { createI18n } from 'vue-i18n';
141
+ import { initiateVueIslands } from '@uselab/vue-islands';
142
+
143
+ const pinia = createPinia();
144
+ const i18n = createI18n({ locale: 'en', messages });
145
+
146
+ initiateVueIslands(components, {
147
+ configureApp: async (app) => {
148
+ app.use(pinia);
149
+ app.use(i18n);
150
+ },
151
+ });
152
+ ```
153
+
154
+ Because `configureApp` runs for every island, a shared Pinia instance or just a simple vue ref object lets independently mounted
155
+ islands stay in sync with each other (and with the rest of the page), and a shared i18n instance
156
+ means every island can use the same translations and locale without reconfiguring it per component.
157
+
158
+ ## Lazy loading components
159
+
160
+ Because a `GetVNode` function is only invoked for elements that actually have a matching
161
+ `data-component` attribute on the page, you can wrap the component import in Vue's
162
+ `defineAsyncComponent`. This splits your code to code-split it into its own chunk that's only fetched when the island is
163
+ actually mounted:
164
+
165
+ ```ts
166
+ import {
167
+ initiateVueIslands,
168
+ createVNodeFunction,
169
+ type GetVNode,
170
+ } from '@uselab/vue-islands';
171
+ import { defineAsyncComponent } from 'vue';
172
+ import MyWidget from './MyWidget.vue';
173
+
174
+ const components: Record<string, GetVNode> = {
175
+ // Loaded eagerly, e.g. because it's needed on (almost) every page.
176
+ 'my-widget': createVNodeFunction(MyWidget),
177
+
178
+ // Loaded lazily: the chunk for `HeavyChart.vue` is only downloaded when a
179
+ // `data-component="heavy-chart"` element is found and mounted.
180
+ 'heavy-chart': createVNodeFunction(
181
+ defineAsyncComponent(() => import('./HeavyChart.vue'))
182
+ ),
183
+ };
184
+
185
+ initiateVueIslands(components);
186
+ ```
187
+
188
+ This keeps the initial bundle small: pages without a `heavy-chart` island never download its code,
189
+ while `defineAsyncComponent` still shows Vue's built-in loading/error states (via its
190
+ `loadingComponent`/`errorComponent` options) while the chunk loads.
191
+
192
+ ## Organizing many components
193
+
194
+ Projects with dozens of islands tend to converge on a small helper that wraps each component in a
195
+ `GetVNode` function, so the `components` map stays a flat, readable list of imports.
196
+ `createVNodeFunction` is exported for this purpose: it passes both the raw and the spread props to
197
+ the component, so components can use `validateRawProps` to log helpful errors (including the
198
+ original, unparsed props) when Zod validation fails:
199
+
200
+ ```ts
201
+ import { defineAsyncComponent } from 'vue';
202
+ import {
203
+ initiateVueIslands,
204
+ createVNodeFunction,
205
+ type GetVNode,
206
+ } from '@uselab/vue-islands';
207
+ import Menu from './features/menu.vue';
208
+ import Header from './features/header.vue';
209
+ import Footer from './features/footer.vue';
210
+
211
+ const components: Record<string, GetVNode> = {
212
+ // Eagerly loaded components, e.g. because they're needed on (almost) every page.
213
+ menu: createVNodeFunction(Menu),
214
+ header: createVNodeFunction(Header),
215
+ footer: createVNodeFunction(Footer),
216
+
217
+ // Lazily loaded components: only fetched when a matching `data-component` element is found.
218
+ heavyChart: createVNodeFunction(
219
+ defineAsyncComponent(() => import('./features/heavy-chart.vue'))
220
+ ),
221
+ imageCarousel: createVNodeFunction(
222
+ defineAsyncComponent(() => import('./features/image-carousel.vue'))
223
+ ),
224
+
225
+ // ...more components
226
+ };
227
+
228
+ initiateVueIslands(components);
229
+ ```
230
+
231
+ ## Validating props with Zod
232
+
233
+ Because props parsed from HTML attributes arrive untyped, pairing `validateRawProps` with a Zod
234
+ schema catches malformed markup early and logs a clear console error — including the original,
235
+ unparsed `rawProps` — instead of failing silently or crashing deep inside the component:
236
+
237
+ ```vue
238
+ <script setup lang="ts">
239
+ import * as z from 'zod';
240
+ import { type WithRawProps, validateRawProps } from '@uselab/vue-islands';
241
+
242
+ type Props = { items: { link?: string; title: string }[] } & WithRawProps;
243
+
244
+ const props = defineProps<Props>();
245
+
246
+ const validator = z.object({
247
+ items: z.array(
248
+ z.object({
249
+ link: z.string().optional(),
250
+ title: z.string(),
251
+ })
252
+ ),
253
+ });
254
+
255
+ validateRawProps<Props>(validator.safeParse(props.rawProps || props), props);
256
+ </script>
257
+ ```
258
+ ## API
259
+
260
+ - `initiateVueIslands(components, options?)` — scans the document (or `options.doc`) for
261
+ `[data-component]` elements and mounts the matching Vue components (see "Declaring components
262
+ and props in HTML" above for the supported `data-*` attributes).
263
+ - `options.isDevMode?: boolean` — enables verbose console logging for debugging (default
264
+ `false`).
265
+ - `options.configureApp?: (app: App) => Promise<void>` — called for each created Vue `App`
266
+ instance before mounting, useful for registering plugins/directives (see "Configuring each
267
+ mounted app" above).
268
+ - `options.doc?: Document` — alternate document to scan (default: global `document`).
269
+ - `createVNodeFunction(component)` — helper that turns a Vue `Component` into a `GetVNode`
270
+ function, passing it both the raw and the spread props (as `WithRawProps`) plus slots. Useful
271
+ when registering many components (see "Organizing many components" above).
272
+ - `validateRawProps(result, props)` — helper for logging Zod prop-validation errors together with
273
+ the raw (unparsed) props that were passed in. Requires `zod` (^4.0.0) to be installed.
274
+
275
+ ## Development
276
+
277
+ ```bash
278
+ npm install
279
+ npm run build
280
+ npm run quality # runs typecheck, lint, and tests
281
+ ```
282
+
283
+ `npm install` also sets up a `pre-push` git hook (via `simple-git-hooks`) that runs
284
+ `npm run quality` (lint, typecheck, and tests) before every `git push`, aborting the push if any of
285
+ them fail.
286
+
287
+ ## Publishing a new version
288
+
289
+ 1. `npm version patch` (or `minor` / `major`) — runs `npm run quality` first (aborting on failure),
290
+ then bumps `package.json`, commits, tags the commit `vX.Y.Z`, and pushes the commit and tag to
291
+ GitHub.
292
+ 2. Pushing the `vX.Y.Z` tag triggers the [CI workflow](.github/workflows/ci.yml), which re-runs
293
+ lint, typecheck, and the test suite on GitHub. Wait for it to pass.
294
+ 3. `npm publish` — builds `dist/` (via the `prepublishOnly` script) and publishes the package to
295
+ npm.
@@ -0,0 +1,36 @@
1
+ import { App } from 'vue';
2
+ import { Component } from 'vue';
3
+ import { Slots } from 'vue';
4
+ import { VNode } from 'vue';
5
+ import { ZodSafeParseResult } from 'zod';
6
+
7
+ export declare const createVNodeFunction: (component: Component) => GetVNode;
8
+
9
+ export declare type GetVNode = (props: GetVNodeProps, slots?: Slots) => VNode;
10
+
11
+ export declare type GetVNodeFunction = (name?: string) => GetVNode | undefined;
12
+
13
+ declare type GetVNodeProps = Record<string, unknown>;
14
+
15
+ export declare const initiateVueIslands: (components: Record<string, GetVNode>, options?: {
16
+ configureApp?: (app: App) => Promise<void>;
17
+ doc?: Document;
18
+ isDevMode?: boolean;
19
+ }) => void;
20
+
21
+ export declare const validateRawProps: <Props extends object & WithRawProps>(result: ZodSafeParseResult<Props>, props: Props) => void;
22
+
23
+ export declare type WithRawProps<Props extends Record<string, unknown> = Record<string, unknown>> = {
24
+ rawProps?: Partial<Props>;
25
+ };
26
+
27
+ export { }
28
+
29
+
30
+ declare global {
31
+ interface Window {
32
+ __vueIslands: {
33
+ mount: (names: string[], options?: MountOptions) => void;
34
+ };
35
+ }
36
+ }
@@ -0,0 +1,85 @@
1
+ import { Comment as e, Text as t, createApp as n, defineComponent as r, getCurrentInstance as i, h as a } from "vue";
2
+ import o from "lodash/kebabCase";
3
+ import { memoize as s } from "lodash";
4
+ import c from "lodash/groupBy";
5
+ import l from "lodash/camelCase";
6
+ import u from "lodash/merge";
7
+ import d from "lodash/set";
8
+ //#region src/dom-props.ts
9
+ var f = (e, ...t) => t.reduce((e, t) => u(e, t), e), p = (e) => e.nodeType === Node.ELEMENT_NODE, m = (e) => o(e).endsWith("-json"), h = (e) => JSON.parse(e.replace(/^True$/, "true").replace(/^False$/, "false")), g = (e, t) => {
10
+ if (t !== void 0) return m(e) ? h(t) : t;
11
+ }, _ = (e, t) => g(t, e.getAttribute(t) ?? void 0), v = (e) => e === "" || e?.toLowerCase() === "true" || e === "1", y = (e) => {
12
+ let t = (e, t) => Array.from(new URLSearchParams(e || "").entries()).reduce((e, [n, r]) => o(r) === "text-content" ? f(e, d({}, n, g(n, (t.textContent || "").trim()))) : f(e, d({}, n, _(t, r))), {}), n = ((e, n) => e.reduce((e, r) => [
13
+ "v-cloak",
14
+ "data-v-cloak",
15
+ "data-component"
16
+ ].includes(o(r.name)) ? e : r.name.toLowerCase() === "data-props" ? f(e, t(r.value, n)) : f(e, d({}, r.name, m(r.name) ? h(r.value) : r.value)), {}))(Array.from(e.attributes), e), r = (e, t = []) => {
17
+ let n = e.nextNode();
18
+ return n === null ? t : r(e, [...t, n]);
19
+ }, i = r(document.createTreeWalker(e, NodeFilter.SHOW_ELEMENT, { acceptNode: (e) => !p(e) || e.dataset.component ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT })).filter((e) => p(e) && e.dataset.props).reduce((e, n) => f(e, p(n) ? t(n.dataset.props, n) : {}), n), a = (e) => {
20
+ let t = o(e), n = /^(data-)?(.*?)(-json)?$/i.exec(t);
21
+ return n ? l(n[2]) : t;
22
+ };
23
+ return Object.entries(i).reduce((e, [t, n]) => ({
24
+ ...e,
25
+ [a(t)]: n
26
+ }), {});
27
+ }, b = (n, r, i) => {
28
+ let o = n.dataset.component, s = r(o);
29
+ o && !s && console.error(`Component ${o} not found.`);
30
+ let l = (n) => n.reduce((n, o) => o.nodeType === Node.TEXT_NODE && o.textContent ? [...n, a(t, o.textContent)] : o.nodeType === Node.COMMENT_NODE && o.textContent ? [...n, a(e, o.textContent)] : p(o) ? (o.dataset.component && o.dataset.keepSemanticHtml !== void 0 && i.isDevMode && console.warn(`[semantic-html-to-vue] data-keep-semantic-html has no effect on nested component "${o.dataset.component}" — it only applies to root data-component elements.`), [...n, b(o, r, i)]) : n, []), u = (e) => {
31
+ let t = Object.entries(c(e, (e) => p(e) && (e.getAttribute("slot") || e.dataset.slot) || "default")).map(([e, t]) => [e, l(t)]);
32
+ return Object.fromEntries(t.map(([e, t]) => [e, () => t]));
33
+ };
34
+ if (s) return i.isDevMode && console.info(`[semantic-html-to-vue] Rendering component: ${o}`), s(y(n), u(Array.from(n.children).filter((e) => p(e) && (!e.dataset.props || e.dataset.component))));
35
+ let d = l(Array.from(n.childNodes)), f = Object.fromEntries(Array.from(n.attributes).map(({ name: e, value: t }) => [e, t]));
36
+ return a(n.tagName, f, d);
37
+ }, x = (e, t, n) => {
38
+ let r = /* @__PURE__ */ new Set([
39
+ "id",
40
+ "class",
41
+ "style",
42
+ "role"
43
+ ]);
44
+ Array.from(e.attributes).forEach(({ name: e, value: n }) => {
45
+ let i = e.toLowerCase();
46
+ (r.has(i) || i.startsWith("aria-")) && t.setAttribute(e, n);
47
+ }), e.parentElement.insertBefore(t, e.nextSibling), n.mount(t), e.replaceWith(t), delete e.dataset.vCloak;
48
+ }, S = (e, t, n) => {
49
+ Array.from(e.childNodes).filter((e) => e.nodeType === Node.TEXT_NODE).forEach((e) => e.remove()), e.removeAttribute("href"), e.appendChild(t), n.mount(t), delete e.dataset.vCloak;
50
+ }, C = (e) => (t, n) => a(e, {
51
+ rawProps: t,
52
+ ...t
53
+ }, n), w = (e, t) => {
54
+ let { configureApp: i, doc: a = document, isDevMode: c = !1 } = t || {}, l = Object.entries(e).map(([e, t]) => [o(e), t]), u = s((e) => {
55
+ if (!e) return;
56
+ let t = o(e), [, n] = l.find(([e]) => e === t) || [];
57
+ return n;
58
+ });
59
+ Array.from(a.querySelectorAll("[data-component]")).filter((e) => !e.parentElement.closest("[data-component]")).reduce((e, t) => {
60
+ let n = o(t.dataset.component);
61
+ return u(n) ? [...e, {
62
+ element: t,
63
+ vNode: b(t, u, { isDevMode: c })
64
+ }] : (console.error(`Component ${n} not found.`), e);
65
+ }, []).forEach(({ element: e, vNode: t }) => {
66
+ (async () => {
67
+ let a = e.dataset.componentTagName ?? e.nodeName.toLowerCase() ?? "div";
68
+ try {
69
+ let o = document.createElement(a);
70
+ o.dataset.mountElement = "";
71
+ let s = n(r(() => () => t));
72
+ i && await i(s), v(e.dataset.keepSemanticHtml) ? S(e, o, s) : x(e, o, s);
73
+ } catch (t) {
74
+ console.error(`[semantic-html-to-vue] Failed to mount component: ${e.dataset.component}`, t);
75
+ }
76
+ })();
77
+ });
78
+ }, T = (e, t) => {
79
+ if (!e.success) {
80
+ let n = i();
81
+ console.error(`Props validation error in component "${n?.type.__name}": ${e.error.message}\n\nRaw props:`, t.rawProps);
82
+ }
83
+ };
84
+ //#endregion
85
+ export { C as createVNodeFunction, w as initiateVueIslands, T as validateRawProps };
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@uselab/vue-islands",
3
+ "version": "1.0.0",
4
+ "description": "Progressively mount Vue components (\"islands\") into server-rendered semantic HTML using data attributes.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/uselab/vue-islands.git"
9
+ },
10
+ "keywords": [
11
+ "vue",
12
+ "islands",
13
+ "islands-architecture",
14
+ "progressive-enhancement",
15
+ "ssr"
16
+ ],
17
+ "type": "module",
18
+ "main": "./dist/vue-islands.js",
19
+ "module": "./dist/vue-islands.js",
20
+ "types": "./dist/vue-islands.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/vue-islands.d.ts",
24
+ "import": "./dist/vue-islands.js"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "sideEffects": false,
31
+ "scripts": {
32
+ "build": "vite build",
33
+ "quality": "npm run lint && npm run typecheck && npm test",
34
+ "lint": "eslint .",
35
+ "lint:fix": "eslint . --fix",
36
+ "typecheck": "tsc --noEmit",
37
+ "test": "playwright test",
38
+ "test:ui": "playwright test --ui",
39
+ "prepublishOnly": "npm run build",
40
+ "prepare": "simple-git-hooks",
41
+ "preversion": "npm run quality",
42
+ "postversion": "git push --follow-tags"
43
+ },
44
+ "simple-git-hooks": {
45
+ "pre-push": "npm run quality"
46
+ },
47
+ "peerDependencies": {
48
+ "vue": "^3.0.0",
49
+ "zod": "^4.0.0"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "zod": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "dependencies": {
57
+ "lodash": "^4.18.1"
58
+ },
59
+ "devDependencies": {
60
+ "@eslint/js": "^10.0.1",
61
+ "@microsoft/api-extractor": "^7.59.0",
62
+ "@playwright/test": "^1.62.1",
63
+ "@types/lodash": "^4.17.25",
64
+ "@types/node": "^26.4.0",
65
+ "eslint": "^10.9.1",
66
+ "eslint-config-prettier": "^10.1.8",
67
+ "eslint-plugin-prettier": "^5.5.6",
68
+ "globals": "^17.11.0",
69
+ "prettier": "^3.9.6",
70
+ "simple-git-hooks": "^2.13.1",
71
+ "typescript": "^6.0.3",
72
+ "typescript-eslint": "^8.68.0",
73
+ "vite": "^8.2.2",
74
+ "vite-plugin-dts": "^5.0.3",
75
+ "vue": "^3.5.42",
76
+ "zod": "^4.4.3"
77
+ }
78
+ }