@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 +295 -0
- package/dist/vue-islands.d.ts +36 -0
- package/dist/vue-islands.js +85 -0
- package/package.json +78 -0
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&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
|
+
}
|