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 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)
@@ -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
@@ -0,0 +1,5 @@
1
+ import registerDirective from './directive.js'
2
+
3
+ export default function alpineRc(Alpine) {
4
+ registerDirective(Alpine)
5
+ }
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
+ }
@@ -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
+ }
@@ -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