alpine-rc 0.1.1
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 +292 -0
- package/dist/alpine-rc.esm.js +6 -0
- package/dist/alpine-rc.min.js +6 -0
- package/package.json +41 -0
- package/src/cache.js +18 -0
- package/src/directive.js +122 -0
- package/src/index.js +5 -0
- package/src/props.js +25 -0
- package/src/slots.js +45 -0
- package/src/styles.js +145 -0
- package/src/template.js +70 -0
- package/types/index.d.ts +138 -0
package/README.md
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# alpine-rc
|
|
2
|
+
|
|
3
|
+
Alpine.js plugin for reusable HTML components. Global styles work out of the box — no config needed. Scoped styles and Shadow DOM isolation are opt-in.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install alpine-rc
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import Alpine from 'alpinejs'
|
|
13
|
+
import alpineRc from 'alpine-rc'
|
|
14
|
+
|
|
15
|
+
Alpine.plugin(alpineRc)
|
|
16
|
+
Alpine.start()
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### CDN
|
|
20
|
+
|
|
21
|
+
Load **before** Alpine:
|
|
22
|
+
|
|
23
|
+
```html
|
|
24
|
+
<script src="https://unpkg.com/alpine-rc/dist/alpine-rc.min.js" defer></script>
|
|
25
|
+
<script src="https://unpkg.com/alpinejs/dist/cdn.min.js" defer></script>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Component files
|
|
31
|
+
|
|
32
|
+
A component is an `.html` file with a `<template>` wrapper:
|
|
33
|
+
|
|
34
|
+
```html
|
|
35
|
+
<!-- components/card.html -->
|
|
36
|
+
<template>
|
|
37
|
+
<style scoped>
|
|
38
|
+
.card { border: 1px solid #ddd; padding: 16px; border-radius: 8px; }
|
|
39
|
+
.card h2 { margin: 0; }
|
|
40
|
+
</style>
|
|
41
|
+
|
|
42
|
+
<div class="card">
|
|
43
|
+
<h2 x-text="title"></h2>
|
|
44
|
+
<slot></slot>
|
|
45
|
+
</div>
|
|
46
|
+
</template>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The `<template>` wrapper is stripped at load time. Styles are handled automatically — see [Styles](#styles).
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Basic usage
|
|
54
|
+
|
|
55
|
+
```html
|
|
56
|
+
<div x-component.url="'./components/card.html'" :title="post.title">
|
|
57
|
+
<template x-slot>
|
|
58
|
+
<p x-text="post.body"></p>
|
|
59
|
+
</template>
|
|
60
|
+
</div>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### From an on-page template
|
|
64
|
+
|
|
65
|
+
```html
|
|
66
|
+
<template id="card">
|
|
67
|
+
<div class="card">
|
|
68
|
+
<h2 x-text="title"></h2>
|
|
69
|
+
<slot></slot>
|
|
70
|
+
</div>
|
|
71
|
+
</template>
|
|
72
|
+
|
|
73
|
+
<div x-component="'card'" :title="post.title"></div>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Props
|
|
79
|
+
|
|
80
|
+
Pass data to the component via `:attr` or `x-bind:attr` bindings on the host element. Props are reactive — when the bound value changes, the component updates without re-rendering.
|
|
81
|
+
|
|
82
|
+
```html
|
|
83
|
+
<div
|
|
84
|
+
x-component.url="'./components/card.html'"
|
|
85
|
+
:title="post.title"
|
|
86
|
+
:count="post.likes"
|
|
87
|
+
:active="selectedId === post.id"
|
|
88
|
+
>
|
|
89
|
+
</div>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Inside the component template, props are available directly:
|
|
93
|
+
|
|
94
|
+
```html
|
|
95
|
+
<template>
|
|
96
|
+
<div :class="{ active }">
|
|
97
|
+
<h2 x-text="title"></h2>
|
|
98
|
+
<span x-text="count"></span>
|
|
99
|
+
</div>
|
|
100
|
+
</template>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The component also inherits the full Alpine scope of the host element, so parent data like `$store` is accessible too.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Slots
|
|
108
|
+
|
|
109
|
+
### Default slot
|
|
110
|
+
|
|
111
|
+
```html
|
|
112
|
+
<div x-component.url="'./card.html'" :title="post.title">
|
|
113
|
+
<template x-slot>
|
|
114
|
+
<p>This goes into the default slot</p>
|
|
115
|
+
</template>
|
|
116
|
+
</div>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```html
|
|
120
|
+
<!-- card.html -->
|
|
121
|
+
<template>
|
|
122
|
+
<div class="card">
|
|
123
|
+
<h2 x-text="title"></h2>
|
|
124
|
+
<slot></slot>
|
|
125
|
+
</div>
|
|
126
|
+
</template>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Named slots
|
|
130
|
+
|
|
131
|
+
```html
|
|
132
|
+
<div x-component.url="'./card.html'" :title="post.title">
|
|
133
|
+
<template x-slot>Main content</template>
|
|
134
|
+
<template x-slot="footer">
|
|
135
|
+
<button @click="save">Save</button>
|
|
136
|
+
</template>
|
|
137
|
+
</div>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```html
|
|
141
|
+
<!-- card.html -->
|
|
142
|
+
<template>
|
|
143
|
+
<div class="card">
|
|
144
|
+
<h2 x-text="title"></h2>
|
|
145
|
+
<slot></slot>
|
|
146
|
+
<footer><slot name="footer"></slot></footer>
|
|
147
|
+
</div>
|
|
148
|
+
</template>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Slots without matching content show their fallback children:
|
|
152
|
+
|
|
153
|
+
```html
|
|
154
|
+
<slot>This renders if no slot content is provided</slot>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Styles
|
|
160
|
+
|
|
161
|
+
### Default — global styles work as-is
|
|
162
|
+
|
|
163
|
+
No configuration needed. Tailwind, CSS, SCSS, and any `<link>` stylesheets apply to the component naturally because it renders in the regular DOM.
|
|
164
|
+
|
|
165
|
+
`<style>` tags inside the component are extracted and injected into `<head>` once per component source.
|
|
166
|
+
|
|
167
|
+
```html
|
|
168
|
+
<!-- x-component.url="'./card.html'" (no modifier) -->
|
|
169
|
+
<template>
|
|
170
|
+
<style>
|
|
171
|
+
/* Injected into <head> once — global scope */
|
|
172
|
+
.card { padding: 16px; }
|
|
173
|
+
</style>
|
|
174
|
+
<div class="card"></div>
|
|
175
|
+
</template>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### `.scoped` — isolated styles via data attribute
|
|
179
|
+
|
|
180
|
+
`<style scoped>` is transformed so selectors only match elements inside this component instance, using a `data-arc-*` attribute (similar to Vue SFC). Global styles and Tailwind still work.
|
|
181
|
+
|
|
182
|
+
```html
|
|
183
|
+
<div x-component.url.scoped="'./card.html'"></div>
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
```html
|
|
187
|
+
<!-- card.html -->
|
|
188
|
+
<template>
|
|
189
|
+
<style scoped>
|
|
190
|
+
/* Scoped: only applies inside this component */
|
|
191
|
+
.card { padding: 16px; }
|
|
192
|
+
</style>
|
|
193
|
+
|
|
194
|
+
<style>
|
|
195
|
+
/* Still global — injected to <head> as-is */
|
|
196
|
+
.badge { border-radius: 99px; }
|
|
197
|
+
</style>
|
|
198
|
+
|
|
199
|
+
<div class="card">...</div>
|
|
200
|
+
</template>
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The plugin generates a stable `data-arc-[hash]` attribute, adds it to every element in the template, and transforms the CSS selectors accordingly.
|
|
204
|
+
|
|
205
|
+
### `.isolated` — Shadow DOM
|
|
206
|
+
|
|
207
|
+
Full style encapsulation. Global CSS and Tailwind do **not** apply inside the component.
|
|
208
|
+
|
|
209
|
+
```html
|
|
210
|
+
<div x-component.url.isolated="'./card.html'"></div>
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
To adopt global stylesheets into the shadow root:
|
|
214
|
+
|
|
215
|
+
```html
|
|
216
|
+
<div x-component.url.isolated.with-styles="'./card.html'"></div>
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Modifiers reference
|
|
222
|
+
|
|
223
|
+
| Modifier | Description |
|
|
224
|
+
|---|---|
|
|
225
|
+
| `.url` | Load template from a same-origin URL |
|
|
226
|
+
| `.url.external` | Load template from a cross-origin URL |
|
|
227
|
+
| `.scoped` | Scope `<style scoped>` via data attribute |
|
|
228
|
+
| `.isolated` | Render in Shadow DOM |
|
|
229
|
+
| `.isolated.with-styles` | Shadow DOM + adopt global document stylesheets |
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## Lifecycle events
|
|
234
|
+
|
|
235
|
+
All events bubble and are dispatched on the host element.
|
|
236
|
+
|
|
237
|
+
```js
|
|
238
|
+
el.addEventListener('rc:loading', ({ detail }) => console.log('loading', detail.source))
|
|
239
|
+
el.addEventListener('rc:loaded', ({ detail }) => console.log('loaded', detail.source))
|
|
240
|
+
el.addEventListener('rc:error', ({ detail }) => console.log('error', detail.error))
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
| Event | Fires when | Detail |
|
|
244
|
+
|---|---|---|
|
|
245
|
+
| `rc:loading` | URL fetch starts (`.url` only) | `{ source }` |
|
|
246
|
+
| `rc:loaded` | Component rendered | `{ source }` |
|
|
247
|
+
| `rc:error` | Expression, fetch, or render failed | `{ source, error }` |
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Cross-origin URLs
|
|
252
|
+
|
|
253
|
+
Same-origin by default. Use `.external` to allow cross-origin:
|
|
254
|
+
|
|
255
|
+
```html
|
|
256
|
+
<div x-component.url.external="'https://cdn.example.com/components/card.html'"></div>
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## TypeScript
|
|
262
|
+
|
|
263
|
+
Types are included. No additional setup needed.
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
import type { RcLoadedEvent } from 'alpine-rc'
|
|
267
|
+
|
|
268
|
+
el.addEventListener('rc:loaded', (e: RcLoadedEvent) => {
|
|
269
|
+
console.log(e.detail.source)
|
|
270
|
+
})
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Production: static HTML baking
|
|
276
|
+
|
|
277
|
+
At build time you can pre-render all components into static HTML using [vite-plugin-bake-alpine-components](https://www.npmjs.com/package/vite-plugin-bake-alpine-components).
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
npm i -D vite-plugin-bake-alpine-components
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
```js
|
|
284
|
+
// vite.config.js
|
|
285
|
+
import bakeAlpineComponents from 'vite-plugin-bake-alpine-components'
|
|
286
|
+
|
|
287
|
+
export default {
|
|
288
|
+
plugins: [bakeAlpineComponents()]
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
The plugin evaluates `x-for`, `x-text`, and `x-component.url` at build time and outputs plain HTML with all data already inlined — no Alpine runtime needed for the initial render.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
var O=new Set(["component","data","init","ignore","ref","bind","on","model","show","if","for","id","class","style"]);function U(t,e){let o={};for(let r of t.attributes){let n=null;if(r.name.startsWith(":")?n=r.name.slice(1):r.name.startsWith("x-bind:")&&(n=r.name.slice(7)),!(!n||O.has(n)||n.startsWith("component")))try{o[n]=e(r.value)}catch{o[n]=r.value}}return o}function A(t){let e={};for(let o of t.querySelectorAll(":scope > template[x-slot]")){let r=(o.getAttribute("x-slot")||"default").trim()||"default";e[r]=o.content.cloneNode(!0)}if(!e.default){let o=[...t.childNodes].filter(r=>!(r.nodeName==="TEMPLATE"&&r.hasAttribute("x-slot")));if(o.length){let r=document.createDocumentFragment();o.forEach(n=>r.appendChild(n.cloneNode(!0))),e.default=r}}return e}function k(t,e){for(let o of t.querySelectorAll("slot[name]")){let r=o.getAttribute("name"),n=e[r];n?o.replaceWith(n.cloneNode(!0)):o.replaceWith(...o.childNodes)}for(let o of t.querySelectorAll("slot:not([name])")){let r=e.default;r?o.replaceWith(r.cloneNode(!0)):o.replaceWith(...o.childNodes)}}function E(t=100){let e=new Map;return e.maxEntries=t,e}function d(t,e,o){for(t.set(e,o);t.size>t.maxEntries;)t.delete(t.keys().next().value)}var w=E(200),y=E(200),p=E(100);function T(t){let e=5381;for(let o=0;o<t.length;o++)e=((e<<5)+e^t.charCodeAt(o))>>>0;return e.toString(36)}function K(t,e){return t.split(",").map(o=>{let r=o.trim();return!r||r===":root"||r==="html"||r==="body"?r:r.replace(/(::[\w-]+)?$/,`[${e}]$1`)}).join(", ")}function F(t,e){if(t.selectorText!==void 0)return`${K(t.selectorText,e)} { ${t.style.cssText} }`;if(t.cssRules){let o=t.cssText.match(/^([^{]+)\{/)?.[1]?.trim()??"",r=Array.from(t.cssRules).map(n=>F(n,e)).join(`
|
|
2
|
+
`);return`${o} {
|
|
3
|
+
${r}
|
|
4
|
+
}`}return t.cssText}function z(t,e){let o=new CSSStyleSheet;try{o.replaceSync(t)}catch{return t}return Array.from(o.cssRules).map(r=>F(r,e)).join(`
|
|
5
|
+
`)}function j(t,e){if(document.getElementById(t))return;let o=document.createElement("style");o.id=t,o.textContent=e,document.head.appendChild(o)}function M(t,e,o,r){let n=[...t.querySelectorAll("style")];if(!n.length)return null;if(r){for(let i of n)i.removeAttribute("scoped");return null}let u=null;for(let i of n){let a=i.textContent.trim(),g=i.hasAttribute("scoped");if(i.remove(),!!a)if(g&&o){let c=`scoped:${e}`;if(!p.has(c)){let S=`data-arc-${T(e)}`,h=z(a,S);d(p,c,S),j(`arc-scoped-${T(e)}`,h)}u=p.get(c)}else{let c=`global:${e}:${T(a)}`;p.has(c)||(d(p,c,!0),j(`arc-global-${T(e+a)}`,a))}}return u}function W(t,e){let o=r=>{r.nodeType===Node.ELEMENT_NODE&&r.setAttribute(e,"");for(let n of r.childNodes)o(n)};for(let r of t.childNodes)o(r)}function B(t){let e=[...document.styleSheets].filter(o=>{try{return o.href&&new URL(o.href,location.href),!o.href||new URL(o.href).origin===location.origin}catch{return!1}}).map(o=>{try{let r=new CSSStyleSheet,n=[...o.cssRules].map(u=>u.cssText).join(`
|
|
6
|
+
`);return r.replaceSync(n),r}catch{return null}}).filter(Boolean);t.adoptedStyleSheets=e}function D(t){let e=document.createElement("template");return e.innerHTML=t,e.content}function G(t){let e=t.match(/^\s*<template[^>]*>([\s\S]*)<\/template>\s*$/i);return e?e[1].trim():t.trim()}function H(t,e){let o=(t??"").trim();if(!o)throw new Error("[alpine-rc] Empty URL");let r=new URL(o,location.href);if(!["http:","https:"].includes(r.protocol))throw new Error(`[alpine-rc] Unsupported protocol: ${r.protocol}`);if(!e&&r.origin!==location.origin)throw new Error(`[alpine-rc] Cross-origin URL blocked: ${r.href}`);return r.href}function P(t){let e=(t??"").trim();if(!e)return null;if(!w.has(e)){let o=document.getElementById(e);if(!o)return console.warn(`[alpine-rc] Template not found: "${e}"`),null;d(w,e,o.innerHTML)}return D(w.get(e)).cloneNode(!0)}async function _(t,{allowExternal:e=!1}={}){let o=H(t,e);y.has(o)||d(y,o,fetch(o).then(r=>{if(!r.ok)throw new Error(`[alpine-rc] Fetch failed (${r.status}): ${o}`);return r.text()}).then(G));try{let r=await y.get(o);return D(r).cloneNode(!0)}catch(r){throw y.delete(o),r}}function b(t,e,o={}){t.dispatchEvent(new CustomEvent(e,{bubbles:!0,composed:!0,detail:o}))}function J(t,e){if(!t)return"";try{let o=e(t);return o==null||o===!1?"":String(o).trim()}catch(o){return console.error("[alpine-rc] Expression error:",o),""}}function C(t,e,o){if(o)t.shadowRoot&&(e.destroyTree(t.shadowRoot),t.shadowRoot.replaceChildren());else{for(let r of[...t.children])e.destroyTree(r);t.replaceChildren(),delete t._x_dataStack}}function $(t){t.directive("component",(e,{expression:o,modifiers:r},{effect:n,cleanup:u,evaluate:i})=>{let a=r.includes("url"),g=r.includes("external"),c=r.includes("isolated"),S=r.includes("scoped"),h=0,f=!1,v=null,m=t.reactive({});n(()=>{let l=J(o,i),R=U(e,i);for(let s of Object.keys(m))s in R||delete m[s];if(Object.assign(m,R),f&&l===v)return;if(v=l,!l){f&&C(e,t,c),f=!1;return}let q=++h;(async()=>{try{a&&b(e,"rc:loading",{source:l});let s=a?await _(l,{allowExternal:g}):P(l);if(q!==h||!s)return;let I=A(e),L=M(s,l,S,c);if(k(s,I),L&&W(s,L),f&&C(e,t,c),c){let x=e.shadowRoot||e.attachShadow({mode:"open"});r.includes("with-styles")&&B(x),t.addScopeToNode(x,m,e),x.replaceChildren(s),t.initTree(x)}else t.addScopeToNode(e,m),e.replaceChildren(s),t.initTree(e);f=!0,b(e,"rc:loaded",{source:l})}catch(s){console.error("[alpine-rc]",s),b(e,"rc:error",{source:l,error:s})}})()}),u(()=>{h++,f&&C(e,t,c)})})}function N(t){$(t)}var ue=N;export{ue as default};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
(()=>{var O=new Set(["component","data","init","ignore","ref","bind","on","model","show","if","for","id","class","style"]);function A(t,e){let o={};for(let r of t.attributes){let n=null;if(r.name.startsWith(":")?n=r.name.slice(1):r.name.startsWith("x-bind:")&&(n=r.name.slice(7)),!(!n||O.has(n)||n.startsWith("component")))try{o[n]=e(r.value)}catch{o[n]=r.value}}return o}function U(t){let e={};for(let o of t.querySelectorAll(":scope > template[x-slot]")){let r=(o.getAttribute("x-slot")||"default").trim()||"default";e[r]=o.content.cloneNode(!0)}if(!e.default){let o=[...t.childNodes].filter(r=>!(r.nodeName==="TEMPLATE"&&r.hasAttribute("x-slot")));if(o.length){let r=document.createDocumentFragment();o.forEach(n=>r.appendChild(n.cloneNode(!0))),e.default=r}}return e}function k(t,e){for(let o of t.querySelectorAll("slot[name]")){let r=o.getAttribute("name"),n=e[r];n?o.replaceWith(n.cloneNode(!0)):o.replaceWith(...o.childNodes)}for(let o of t.querySelectorAll("slot:not([name])")){let r=e.default;r?o.replaceWith(r.cloneNode(!0)):o.replaceWith(...o.childNodes)}}function E(t=100){let e=new Map;return e.maxEntries=t,e}function d(t,e,o){for(t.set(e,o);t.size>t.maxEntries;)t.delete(t.keys().next().value)}var x=E(200),y=E(200),p=E(100);function g(t){let e=5381;for(let o=0;o<t.length;o++)e=((e<<5)+e^t.charCodeAt(o))>>>0;return e.toString(36)}function K(t,e){return t.split(",").map(o=>{let r=o.trim();return!r||r===":root"||r==="html"||r==="body"?r:r.replace(/(::[\w-]+)?$/,`[${e}]$1`)}).join(", ")}function F(t,e){if(t.selectorText!==void 0)return`${K(t.selectorText,e)} { ${t.style.cssText} }`;if(t.cssRules){let o=t.cssText.match(/^([^{]+)\{/)?.[1]?.trim()??"",r=Array.from(t.cssRules).map(n=>F(n,e)).join(`
|
|
2
|
+
`);return`${o} {
|
|
3
|
+
${r}
|
|
4
|
+
}`}return t.cssText}function z(t,e){let o=new CSSStyleSheet;try{o.replaceSync(t)}catch{return t}return Array.from(o.cssRules).map(r=>F(r,e)).join(`
|
|
5
|
+
`)}function j(t,e){if(document.getElementById(t))return;let o=document.createElement("style");o.id=t,o.textContent=e,document.head.appendChild(o)}function M(t,e,o,r){let n=[...t.querySelectorAll("style")];if(!n.length)return null;if(r){for(let i of n)i.removeAttribute("scoped");return null}let u=null;for(let i of n){let a=i.textContent.trim(),T=i.hasAttribute("scoped");if(i.remove(),!!a)if(T&&o){let c=`scoped:${e}`;if(!p.has(c)){let S=`data-arc-${g(e)}`,h=z(a,S);d(p,c,S),j(`arc-scoped-${g(e)}`,h)}u=p.get(c)}else{let c=`global:${e}:${g(a)}`;p.has(c)||(d(p,c,!0),j(`arc-global-${g(e+a)}`,a))}}return u}function W(t,e){let o=r=>{r.nodeType===Node.ELEMENT_NODE&&r.setAttribute(e,"");for(let n of r.childNodes)o(n)};for(let r of t.childNodes)o(r)}function B(t){let e=[...document.styleSheets].filter(o=>{try{return o.href&&new URL(o.href,location.href),!o.href||new URL(o.href).origin===location.origin}catch{return!1}}).map(o=>{try{let r=new CSSStyleSheet,n=[...o.cssRules].map(u=>u.cssText).join(`
|
|
6
|
+
`);return r.replaceSync(n),r}catch{return null}}).filter(Boolean);t.adoptedStyleSheets=e}function D(t){let e=document.createElement("template");return e.innerHTML=t,e.content}function G(t){let e=t.match(/^\s*<template[^>]*>([\s\S]*)<\/template>\s*$/i);return e?e[1].trim():t.trim()}function H(t,e){let o=(t??"").trim();if(!o)throw new Error("[alpine-rc] Empty URL");let r=new URL(o,location.href);if(!["http:","https:"].includes(r.protocol))throw new Error(`[alpine-rc] Unsupported protocol: ${r.protocol}`);if(!e&&r.origin!==location.origin)throw new Error(`[alpine-rc] Cross-origin URL blocked: ${r.href}`);return r.href}function P(t){let e=(t??"").trim();if(!e)return null;if(!x.has(e)){let o=document.getElementById(e);if(!o)return console.warn(`[alpine-rc] Template not found: "${e}"`),null;d(x,e,o.innerHTML)}return D(x.get(e)).cloneNode(!0)}async function _(t,{allowExternal:e=!1}={}){let o=H(t,e);y.has(o)||d(y,o,fetch(o).then(r=>{if(!r.ok)throw new Error(`[alpine-rc] Fetch failed (${r.status}): ${o}`);return r.text()}).then(G));try{let r=await y.get(o);return D(r).cloneNode(!0)}catch(r){throw y.delete(o),r}}function b(t,e,o={}){t.dispatchEvent(new CustomEvent(e,{bubbles:!0,composed:!0,detail:o}))}function J(t,e){if(!t)return"";try{let o=e(t);return o==null||o===!1?"":String(o).trim()}catch(o){return console.error("[alpine-rc] Expression error:",o),""}}function C(t,e,o){if(o)t.shadowRoot&&(e.destroyTree(t.shadowRoot),t.shadowRoot.replaceChildren());else{for(let r of[...t.children])e.destroyTree(r);t.replaceChildren(),delete t._x_dataStack}}function $(t){t.directive("component",(e,{expression:o,modifiers:r},{effect:n,cleanup:u,evaluate:i})=>{let a=r.includes("url"),T=r.includes("external"),c=r.includes("isolated"),S=r.includes("scoped"),h=0,f=!1,N=null,m=t.reactive({});n(()=>{let l=J(o,i),R=A(e,i);for(let s of Object.keys(m))s in R||delete m[s];if(Object.assign(m,R),f&&l===N)return;if(N=l,!l){f&&C(e,t,c),f=!1;return}let q=++h;(async()=>{try{a&&b(e,"rc:loading",{source:l});let s=a?await _(l,{allowExternal:T}):P(l);if(q!==h||!s)return;let I=U(e),L=M(s,l,S,c);if(k(s,I),L&&W(s,L),f&&C(e,t,c),c){let w=e.shadowRoot||e.attachShadow({mode:"open"});r.includes("with-styles")&&B(w),t.addScopeToNode(w,m,e),w.replaceChildren(s),t.initTree(w)}else t.addScopeToNode(e,m),e.replaceChildren(s),t.initTree(e);f=!0,b(e,"rc:loaded",{source:l})}catch(s){console.error("[alpine-rc]",s),b(e,"rc:error",{source:l,error:s})}})()}),u(()=>{h++,f&&C(e,t,c)})})}function v(t){$(t)}document.addEventListener("alpine:init",()=>window.Alpine.plugin(v));})();
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "alpine-rc",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Alpine.js component plugin — global styles by default, scoped styles on demand, reactive props, slots",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"alpinejs",
|
|
7
|
+
"alpine-rc",
|
|
8
|
+
"components",
|
|
9
|
+
"scoped-css",
|
|
10
|
+
"slots"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"types",
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"main": "dist/alpine-rc.esm.js",
|
|
20
|
+
"module": "dist/alpine-rc.esm.js",
|
|
21
|
+
"types": "types/index.d.ts",
|
|
22
|
+
"unpkg": "dist/alpine-rc.min.js",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"import": "./dist/alpine-rc.esm.js",
|
|
26
|
+
"types": "./types/index.d.ts",
|
|
27
|
+
"default": "./dist/alpine-rc.esm.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "node scripts/build.js",
|
|
32
|
+
"lint": "eslint . --fix",
|
|
33
|
+
"prebuild": "npm run lint"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@eslint/js": "^10.0.1",
|
|
37
|
+
"esbuild": "^0.27.3",
|
|
38
|
+
"eslint": "^10.0.3",
|
|
39
|
+
"globals": "^17.4.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/cache.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const DEFAULT_LIMIT = 100
|
|
2
|
+
|
|
3
|
+
function createCache(limit = DEFAULT_LIMIT) {
|
|
4
|
+
const cache = new Map()
|
|
5
|
+
cache.maxEntries = limit
|
|
6
|
+
return cache
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function setBounded(cache, key, value) {
|
|
10
|
+
cache.set(key, value)
|
|
11
|
+
while (cache.size > cache.maxEntries) {
|
|
12
|
+
cache.delete(cache.keys().next().value)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const templateCache = createCache(200)
|
|
17
|
+
export const remoteCache = createCache(200)
|
|
18
|
+
export const styleCache = createCache(100)
|
package/src/directive.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { parseProps } from './props.js'
|
|
2
|
+
import { collectSlots, projectSlots } from './slots.js'
|
|
3
|
+
import { processStyles, applyScopeToAll, adoptGlobalStyles } from './styles.js'
|
|
4
|
+
import { loadFromTemplate, loadFromUrl } from './template.js'
|
|
5
|
+
|
|
6
|
+
function dispatch(el, name, detail = {}) {
|
|
7
|
+
el.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail }))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function resolveSource(expression, evaluate) {
|
|
11
|
+
if (!expression) return ''
|
|
12
|
+
try {
|
|
13
|
+
const val = evaluate(expression)
|
|
14
|
+
if (val == null || val === false) return ''
|
|
15
|
+
return String(val).trim()
|
|
16
|
+
} catch (err) {
|
|
17
|
+
console.error('[alpine-rc] Expression error:', err)
|
|
18
|
+
return ''
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function unmount(el, Alpine, isIsolated) {
|
|
23
|
+
if (isIsolated) {
|
|
24
|
+
if (el.shadowRoot) {
|
|
25
|
+
Alpine.destroyTree(el.shadowRoot)
|
|
26
|
+
el.shadowRoot.replaceChildren()
|
|
27
|
+
}
|
|
28
|
+
} else {
|
|
29
|
+
for (const child of [...el.children]) Alpine.destroyTree(child)
|
|
30
|
+
el.replaceChildren()
|
|
31
|
+
// Remove scope we added
|
|
32
|
+
delete el._x_dataStack
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function registerDirective(Alpine) {
|
|
37
|
+
Alpine.directive(
|
|
38
|
+
'component',
|
|
39
|
+
(el, { expression, modifiers }, { effect, cleanup, evaluate }) => {
|
|
40
|
+
const isUrl = modifiers.includes('url')
|
|
41
|
+
const isExternal = modifiers.includes('external')
|
|
42
|
+
const isIsolated = modifiers.includes('isolated')
|
|
43
|
+
const isScoped = modifiers.includes('scoped')
|
|
44
|
+
|
|
45
|
+
let renderToken = 0
|
|
46
|
+
let mounted = false
|
|
47
|
+
let lastSource = null
|
|
48
|
+
|
|
49
|
+
// Reactive props object — mutations propagate into the component automatically
|
|
50
|
+
const reactiveProps = Alpine.reactive({})
|
|
51
|
+
|
|
52
|
+
effect(() => {
|
|
53
|
+
const source = resolveSource(expression, evaluate)
|
|
54
|
+
const newProps = parseProps(el, evaluate)
|
|
55
|
+
|
|
56
|
+
// Sync reactive props (Alpine reactivity in component picks up changes)
|
|
57
|
+
for (const key of Object.keys(reactiveProps)) {
|
|
58
|
+
if (!(key in newProps)) delete reactiveProps[key]
|
|
59
|
+
}
|
|
60
|
+
Object.assign(reactiveProps, newProps)
|
|
61
|
+
|
|
62
|
+
// Skip re-render if only props changed
|
|
63
|
+
if (mounted && source === lastSource) return
|
|
64
|
+
lastSource = source
|
|
65
|
+
|
|
66
|
+
if (!source) {
|
|
67
|
+
if (mounted) unmount(el, Alpine, isIsolated)
|
|
68
|
+
mounted = false
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const token = ++renderToken
|
|
73
|
+
|
|
74
|
+
;(async () => {
|
|
75
|
+
try {
|
|
76
|
+
if (isUrl) dispatch(el, 'rc:loading', { source })
|
|
77
|
+
|
|
78
|
+
const fragment = isUrl
|
|
79
|
+
? await loadFromUrl(source, { allowExternal: isExternal })
|
|
80
|
+
: loadFromTemplate(source)
|
|
81
|
+
|
|
82
|
+
if (token !== renderToken || !fragment) return
|
|
83
|
+
|
|
84
|
+
const slots = collectSlots(el)
|
|
85
|
+
const scopeAttr = processStyles(fragment, source, isScoped, isIsolated)
|
|
86
|
+
|
|
87
|
+
projectSlots(fragment, slots)
|
|
88
|
+
|
|
89
|
+
if (scopeAttr) applyScopeToAll(fragment, scopeAttr)
|
|
90
|
+
|
|
91
|
+
if (mounted) unmount(el, Alpine, isIsolated)
|
|
92
|
+
|
|
93
|
+
if (isIsolated) {
|
|
94
|
+
const shadow = el.shadowRoot || el.attachShadow({ mode: 'open' })
|
|
95
|
+
|
|
96
|
+
if (modifiers.includes('with-styles')) adoptGlobalStyles(shadow)
|
|
97
|
+
|
|
98
|
+
Alpine.addScopeToNode(shadow, reactiveProps, el)
|
|
99
|
+
shadow.replaceChildren(fragment)
|
|
100
|
+
Alpine.initTree(shadow)
|
|
101
|
+
} else {
|
|
102
|
+
Alpine.addScopeToNode(el, reactiveProps)
|
|
103
|
+
el.replaceChildren(fragment)
|
|
104
|
+
Alpine.initTree(el)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
mounted = true
|
|
108
|
+
dispatch(el, 'rc:loaded', { source })
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.error('[alpine-rc]', err)
|
|
111
|
+
dispatch(el, 'rc:error', { source, error: err })
|
|
112
|
+
}
|
|
113
|
+
})()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
cleanup(() => {
|
|
117
|
+
renderToken++
|
|
118
|
+
if (mounted) unmount(el, Alpine, isIsolated)
|
|
119
|
+
})
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
}
|
package/src/index.js
ADDED
package/src/props.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const SKIP = new Set(['component', 'data', 'init', 'ignore', 'ref', 'bind', 'on', 'model', 'show', 'if', 'for', 'id', 'class', 'style'])
|
|
2
|
+
|
|
3
|
+
export function parseProps(el, evaluate) {
|
|
4
|
+
const props = {}
|
|
5
|
+
|
|
6
|
+
for (const attr of el.attributes) {
|
|
7
|
+
let name = null
|
|
8
|
+
|
|
9
|
+
if (attr.name.startsWith(':')) {
|
|
10
|
+
name = attr.name.slice(1)
|
|
11
|
+
} else if (attr.name.startsWith('x-bind:')) {
|
|
12
|
+
name = attr.name.slice(7)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!name || SKIP.has(name) || name.startsWith('component')) continue
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
props[name] = evaluate(attr.value)
|
|
19
|
+
} catch {
|
|
20
|
+
props[name] = attr.value
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return props
|
|
25
|
+
}
|
package/src/slots.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function collectSlots(el) {
|
|
2
|
+
const slots = {}
|
|
3
|
+
|
|
4
|
+
for (const tpl of el.querySelectorAll(':scope > template[x-slot]')) {
|
|
5
|
+
const name = (tpl.getAttribute('x-slot') || 'default').trim() || 'default'
|
|
6
|
+
slots[name] = tpl.content.cloneNode(true)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Children that are not x-slot templates become the default slot
|
|
10
|
+
if (!slots.default) {
|
|
11
|
+
const nonSlot = [...el.childNodes].filter(
|
|
12
|
+
(n) => !(n.nodeName === 'TEMPLATE' && n.hasAttribute('x-slot')),
|
|
13
|
+
)
|
|
14
|
+
if (nonSlot.length) {
|
|
15
|
+
const frag = document.createDocumentFragment()
|
|
16
|
+
nonSlot.forEach((n) => frag.appendChild(n.cloneNode(true)))
|
|
17
|
+
slots.default = frag
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return slots
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function projectSlots(fragment, slots) {
|
|
25
|
+
// Named slots: <slot name="footer"></slot>
|
|
26
|
+
for (const slot of fragment.querySelectorAll('slot[name]')) {
|
|
27
|
+
const name = slot.getAttribute('name')
|
|
28
|
+
const content = slots[name]
|
|
29
|
+
if (content) {
|
|
30
|
+
slot.replaceWith(content.cloneNode(true))
|
|
31
|
+
} else {
|
|
32
|
+
slot.replaceWith(...slot.childNodes)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Default slot: <slot></slot> or <slot />
|
|
37
|
+
for (const slot of fragment.querySelectorAll('slot:not([name])')) {
|
|
38
|
+
const content = slots.default
|
|
39
|
+
if (content) {
|
|
40
|
+
slot.replaceWith(content.cloneNode(true))
|
|
41
|
+
} else {
|
|
42
|
+
slot.replaceWith(...slot.childNodes)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/styles.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { styleCache, setBounded } from './cache.js'
|
|
2
|
+
|
|
3
|
+
function hashString(str) {
|
|
4
|
+
let h = 5381
|
|
5
|
+
for (let i = 0; i < str.length; i++) {
|
|
6
|
+
h = (((h << 5) + h) ^ str.charCodeAt(i)) >>> 0
|
|
7
|
+
}
|
|
8
|
+
return h.toString(36)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function scopeSelector(selector, attr) {
|
|
12
|
+
return selector
|
|
13
|
+
.split(',')
|
|
14
|
+
.map((s) => {
|
|
15
|
+
const t = s.trim()
|
|
16
|
+
if (!t || t === ':root' || t === 'html' || t === 'body') return t
|
|
17
|
+
// Insert attr before pseudo-element if any (e.g. ::before)
|
|
18
|
+
return t.replace(/(::[\w-]+)?$/, `[${attr}]$1`)
|
|
19
|
+
})
|
|
20
|
+
.join(', ')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function scopeRule(rule, attr) {
|
|
24
|
+
// Style rule — scope selectors
|
|
25
|
+
if (rule.selectorText !== undefined) {
|
|
26
|
+
const selector = scopeSelector(rule.selectorText, attr)
|
|
27
|
+
return `${selector} { ${rule.style.cssText} }`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Grouping rule (@media, @supports, @layer, @container)
|
|
31
|
+
if (rule.cssRules) {
|
|
32
|
+
const prefix = rule.cssText.match(/^([^{]+)\{/)?.[1]?.trim() ?? ''
|
|
33
|
+
const inner = Array.from(rule.cssRules)
|
|
34
|
+
.map((r) => scopeRule(r, attr))
|
|
35
|
+
.join('\n')
|
|
36
|
+
return `${prefix} {\n${inner}\n}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return rule.cssText
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildScopedCss(cssText, attr) {
|
|
43
|
+
const sheet = new CSSStyleSheet()
|
|
44
|
+
try {
|
|
45
|
+
sheet.replaceSync(cssText)
|
|
46
|
+
} catch {
|
|
47
|
+
return cssText
|
|
48
|
+
}
|
|
49
|
+
return Array.from(sheet.cssRules)
|
|
50
|
+
.map((r) => scopeRule(r, attr))
|
|
51
|
+
.join('\n')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function injectStyle(id, cssText) {
|
|
55
|
+
if (document.getElementById(id)) return
|
|
56
|
+
const el = document.createElement('style')
|
|
57
|
+
el.id = id
|
|
58
|
+
el.textContent = cssText
|
|
59
|
+
document.head.appendChild(el)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Extract <style> and <style scoped> from fragment.
|
|
64
|
+
* Returns scope attribute name if scoped styles were processed, otherwise null.
|
|
65
|
+
*
|
|
66
|
+
* Modes:
|
|
67
|
+
* normal — <style> injected to <head> once, <style scoped> treated as global
|
|
68
|
+
* scoped — <style scoped> scoped via data attr and injected, <style> global
|
|
69
|
+
* isolated — styles stay in fragment (Shadow DOM handles isolation)
|
|
70
|
+
*/
|
|
71
|
+
export function processStyles(fragment, sourceId, isScoped, isIsolated) {
|
|
72
|
+
const styleEls = [...fragment.querySelectorAll('style')]
|
|
73
|
+
if (!styleEls.length) return null
|
|
74
|
+
|
|
75
|
+
if (isIsolated) {
|
|
76
|
+
// Remove 'scoped' attribute so it doesn't affect anything — shadow DOM isolates naturally
|
|
77
|
+
for (const el of styleEls) el.removeAttribute('scoped')
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let scopeAttr = null
|
|
82
|
+
|
|
83
|
+
for (const el of styleEls) {
|
|
84
|
+
const cssText = el.textContent.trim()
|
|
85
|
+
const hasScoped = el.hasAttribute('scoped')
|
|
86
|
+
el.remove()
|
|
87
|
+
|
|
88
|
+
if (!cssText) continue
|
|
89
|
+
|
|
90
|
+
if (hasScoped && isScoped) {
|
|
91
|
+
// Build scoped attr once per component source
|
|
92
|
+
const cacheKey = `scoped:${sourceId}`
|
|
93
|
+
if (!styleCache.has(cacheKey)) {
|
|
94
|
+
const attr = `data-arc-${hashString(sourceId)}`
|
|
95
|
+
const scoped = buildScopedCss(cssText, attr)
|
|
96
|
+
setBounded(styleCache, cacheKey, attr)
|
|
97
|
+
injectStyle(`arc-scoped-${hashString(sourceId)}`, scoped)
|
|
98
|
+
}
|
|
99
|
+
scopeAttr = styleCache.get(cacheKey)
|
|
100
|
+
} else {
|
|
101
|
+
// Global — inject once
|
|
102
|
+
const cacheKey = `global:${sourceId}:${hashString(cssText)}`
|
|
103
|
+
if (!styleCache.has(cacheKey)) {
|
|
104
|
+
setBounded(styleCache, cacheKey, true)
|
|
105
|
+
injectStyle(`arc-global-${hashString(sourceId + cssText)}`, cssText)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return scopeAttr
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function applyScopeToAll(fragment, attr) {
|
|
114
|
+
const walk = (node) => {
|
|
115
|
+
if (node.nodeType === Node.ELEMENT_NODE) node.setAttribute(attr, '')
|
|
116
|
+
for (const child of node.childNodes) walk(child)
|
|
117
|
+
}
|
|
118
|
+
for (const child of fragment.childNodes) walk(child)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// For .isolated mode — adopt global document stylesheets into shadow root
|
|
122
|
+
export function adoptGlobalStyles(shadowRoot) {
|
|
123
|
+
const sheets = [...document.styleSheets]
|
|
124
|
+
.filter((s) => {
|
|
125
|
+
try {
|
|
126
|
+
if (s.href) new URL(s.href, location.href) // throws if invalid
|
|
127
|
+
return !s.href || new URL(s.href).origin === location.origin
|
|
128
|
+
} catch {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
.map((s) => {
|
|
133
|
+
try {
|
|
134
|
+
const sheet = new CSSStyleSheet()
|
|
135
|
+
const css = [...s.cssRules].map((r) => r.cssText).join('\n')
|
|
136
|
+
sheet.replaceSync(css)
|
|
137
|
+
return sheet
|
|
138
|
+
} catch {
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
.filter(Boolean)
|
|
143
|
+
|
|
144
|
+
shadowRoot.adoptedStyleSheets = sheets
|
|
145
|
+
}
|
package/src/template.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { templateCache, remoteCache, setBounded } from './cache.js'
|
|
2
|
+
|
|
3
|
+
function toFragment(html) {
|
|
4
|
+
const tpl = document.createElement('template')
|
|
5
|
+
tpl.innerHTML = html
|
|
6
|
+
return tpl.content
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function stripOuterTemplate(html) {
|
|
10
|
+
const m = html.match(/^\s*<template[^>]*>([\s\S]*)<\/template>\s*$/i)
|
|
11
|
+
return m ? m[1].trim() : html.trim()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolveUrl(url, allowExternal) {
|
|
15
|
+
const normalized = (url ?? '').trim()
|
|
16
|
+
if (!normalized) throw new Error('[alpine-rc] Empty URL')
|
|
17
|
+
|
|
18
|
+
const resolved = new URL(normalized, location.href)
|
|
19
|
+
|
|
20
|
+
if (!['http:', 'https:'].includes(resolved.protocol)) {
|
|
21
|
+
throw new Error(`[alpine-rc] Unsupported protocol: ${resolved.protocol}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!allowExternal && resolved.origin !== location.origin) {
|
|
25
|
+
throw new Error(`[alpine-rc] Cross-origin URL blocked: ${resolved.href}`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return resolved.href
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function loadFromTemplate(id) {
|
|
32
|
+
const key = (id ?? '').trim()
|
|
33
|
+
if (!key) return null
|
|
34
|
+
|
|
35
|
+
if (!templateCache.has(key)) {
|
|
36
|
+
const el = document.getElementById(key)
|
|
37
|
+
if (!el) {
|
|
38
|
+
console.warn(`[alpine-rc] Template not found: "${key}"`)
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
setBounded(templateCache, key, el.innerHTML)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return toFragment(templateCache.get(key)).cloneNode(true)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function loadFromUrl(url, { allowExternal = false } = {}) {
|
|
48
|
+
const resolved = resolveUrl(url, allowExternal)
|
|
49
|
+
|
|
50
|
+
if (!remoteCache.has(resolved)) {
|
|
51
|
+
setBounded(
|
|
52
|
+
remoteCache,
|
|
53
|
+
resolved,
|
|
54
|
+
fetch(resolved)
|
|
55
|
+
.then((r) => {
|
|
56
|
+
if (!r.ok) throw new Error(`[alpine-rc] Fetch failed (${r.status}): ${resolved}`)
|
|
57
|
+
return r.text()
|
|
58
|
+
})
|
|
59
|
+
.then(stripOuterTemplate),
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const html = await remoteCache.get(resolved)
|
|
65
|
+
return toFragment(html).cloneNode(true)
|
|
66
|
+
} catch (err) {
|
|
67
|
+
remoteCache.delete(resolved)
|
|
68
|
+
throw err
|
|
69
|
+
}
|
|
70
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { Alpine } from 'alpinejs'
|
|
2
|
+
|
|
3
|
+
// ─── Plugin ──────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Alpine RC plugin. Register with `Alpine.plugin(alpineRc)`.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* import Alpine from 'alpinejs'
|
|
10
|
+
* import alpineRc from 'alpine-rc'
|
|
11
|
+
*
|
|
12
|
+
* Alpine.plugin(alpineRc)
|
|
13
|
+
* Alpine.start()
|
|
14
|
+
*/
|
|
15
|
+
declare function alpineRc(Alpine: Alpine): void
|
|
16
|
+
|
|
17
|
+
export default alpineRc
|
|
18
|
+
|
|
19
|
+
// ─── Directive modifiers ──────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `x-component` directive modifiers.
|
|
23
|
+
*
|
|
24
|
+
* - (none) — render inline, global styles work (Tailwind, CSS, SCSS)
|
|
25
|
+
* - `url` — load template from a same-origin URL
|
|
26
|
+
* - `url.external` — load template from a cross-origin URL
|
|
27
|
+
* - `scoped` — scope `<style scoped>` via data attribute (no Shadow DOM)
|
|
28
|
+
* - `isolated` — render in Shadow DOM (full style isolation)
|
|
29
|
+
* - `isolated.with-styles` — Shadow DOM + adopt global document stylesheets
|
|
30
|
+
*/
|
|
31
|
+
export type ComponentModifier =
|
|
32
|
+
| 'url'
|
|
33
|
+
| 'external'
|
|
34
|
+
| 'scoped'
|
|
35
|
+
| 'isolated'
|
|
36
|
+
| 'with-styles'
|
|
37
|
+
|
|
38
|
+
// ─── Lifecycle events ─────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export interface RcLoadingDetail {
|
|
41
|
+
source: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RcLoadedDetail {
|
|
45
|
+
source: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RcErrorDetail {
|
|
49
|
+
source: string
|
|
50
|
+
error: Error
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Dispatched on the host element when a URL component starts fetching.
|
|
55
|
+
* Only fires with the `.url` modifier.
|
|
56
|
+
*/
|
|
57
|
+
export type RcLoadingEvent = CustomEvent<RcLoadingDetail>
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Dispatched on the host element after the component is fully rendered.
|
|
61
|
+
*/
|
|
62
|
+
export type RcLoadedEvent = CustomEvent<RcLoadedDetail>
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Dispatched on the host element when rendering fails.
|
|
66
|
+
*/
|
|
67
|
+
export type RcErrorEvent = CustomEvent<RcErrorDetail>
|
|
68
|
+
|
|
69
|
+
// ─── Alpine augmentation ──────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
declare module 'alpinejs' {
|
|
72
|
+
interface XAttributes {
|
|
73
|
+
/**
|
|
74
|
+
* Renders a component into the host element.
|
|
75
|
+
*
|
|
76
|
+
* **From on-page template:**
|
|
77
|
+
* ```html
|
|
78
|
+
* <div x-component="'card'"></div>
|
|
79
|
+
* ```
|
|
80
|
+
*
|
|
81
|
+
* **From URL (same-origin by default):**
|
|
82
|
+
* ```html
|
|
83
|
+
* <div x-component.url="'./components/card.html'"></div>
|
|
84
|
+
* ```
|
|
85
|
+
*
|
|
86
|
+
* **With scoped styles:**
|
|
87
|
+
* ```html
|
|
88
|
+
* <div x-component.url.scoped="'./components/card.html'"></div>
|
|
89
|
+
* ```
|
|
90
|
+
*
|
|
91
|
+
* **With Shadow DOM isolation:**
|
|
92
|
+
* ```html
|
|
93
|
+
* <div x-component.url.isolated="'./components/card.html'"></div>
|
|
94
|
+
* ```
|
|
95
|
+
*
|
|
96
|
+
* **Pass props via `:attr` bindings:**
|
|
97
|
+
* ```html
|
|
98
|
+
* <div x-component.url="'./card.html'" :title="post.title" :count="likes"></div>
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
'x-component': string
|
|
102
|
+
'x-component.url': string
|
|
103
|
+
'x-component.url.external': string
|
|
104
|
+
'x-component.url.scoped': string
|
|
105
|
+
'x-component.url.isolated': string
|
|
106
|
+
'x-component.url.isolated.with-styles': string
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Component file format ────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A component file (`*.html`) can be structured as:
|
|
114
|
+
*
|
|
115
|
+
* ```html
|
|
116
|
+
* <template>
|
|
117
|
+
* <!-- Global style — injected into <head> once -->
|
|
118
|
+
* <style>
|
|
119
|
+
* .card { border: 1px solid #ddd; }
|
|
120
|
+
* </style>
|
|
121
|
+
*
|
|
122
|
+
* <!-- Scoped style — only active when host uses .scoped modifier -->
|
|
123
|
+
* <style scoped>
|
|
124
|
+
* .card { padding: 16px; }
|
|
125
|
+
* </style>
|
|
126
|
+
*
|
|
127
|
+
* <div class="card">
|
|
128
|
+
* <h2 x-text="title"></h2>
|
|
129
|
+
* <slot></slot>
|
|
130
|
+
* <slot name="footer"></slot>
|
|
131
|
+
* </div>
|
|
132
|
+
* </template>
|
|
133
|
+
* ```
|
|
134
|
+
*
|
|
135
|
+
* Props declared via `:attr` on the host element are available
|
|
136
|
+
* as reactive data inside the component template.
|
|
137
|
+
*/
|
|
138
|
+
export type ComponentFile = never
|