@elyndra/astro-effects 1.2.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/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@elyndra/astro-effects",
3
+ "version": "1.2.0",
4
+ "sideEffects": false,
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "description": "Elyndra effects components for Astro (SSR shells with vanilla client hydration)",
9
+ "keywords": [
10
+ "elyndra",
11
+ "ui",
12
+ "front-end",
13
+ "framework",
14
+ "scifi",
15
+ "sci-fi",
16
+ "science-fiction",
17
+ "astro"
18
+ ],
19
+ "homepage": "https://elyndra.dev",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/Gabox301/elyndra.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/Gabox301/elyndra/issues"
26
+ },
27
+ "funding": "https://github.com/sponsors/romelperez",
28
+ "license": "MIT",
29
+ "files": [
30
+ "src"
31
+ ],
32
+ "exports": {
33
+ ".": "./src/index.ts",
34
+ "./Illuminator": "./src/Illuminator/Illuminator.astro",
35
+ "./IlluminatorSVG": "./src/IlluminatorSVG/IlluminatorSVG.astro",
36
+ "./client": "./src/client/effectsClient.ts"
37
+ },
38
+ "types": "./src/index.ts",
39
+ "module": "./src/index.ts",
40
+ "main": "./src/index.ts",
41
+ "peerDependencies": {
42
+ "astro": "7.3.2"
43
+ },
44
+ "dependencies": {
45
+ "@elyndra/effects": "^1.2.0",
46
+ "@elyndra/tools": "^1.2.0",
47
+ "tslib": "2.8.1"
48
+ },
49
+ "scripts": {
50
+ "build": "tsc --noEmit -p tsconfig.json"
51
+ }
52
+ }
@@ -0,0 +1,31 @@
1
+ ---
2
+ import type { IlluminatorElementProps } from '../types.js';
3
+
4
+ interface Props extends IlluminatorElementProps {}
5
+
6
+ const { className, ...settings } = Astro.props as Props;
7
+
8
+ // Ids only need page-level uniqueness: the component script consumes them in
9
+ // the same render.
10
+ const nodeId = `elyndra-illuminator-${Math.random().toString(36).slice(2)}`;
11
+ const serializable = { ...settings };
12
+ ---
13
+
14
+ <div
15
+ id={nodeId}
16
+ data-elyndra-illuminator
17
+ role="presentation"
18
+ class={className}
19
+ style="position: absolute; inset: 0; overflow: hidden; width: 100%; height: 100%;"
20
+ >
21
+ <slot />
22
+ </div>
23
+
24
+ <script define:vars={{ nodeId, serializable }}>
25
+ import { initIlluminatorElement } from '../client/effectsClient.js';
26
+
27
+ const element = document.getElementById(nodeId);
28
+ if (element instanceof HTMLElement) {
29
+ initIlluminatorElement(element, serializable);
30
+ }
31
+ </script>
@@ -0,0 +1,25 @@
1
+ // @vitest-environment node
2
+ // Render tests assert on SSR strings, so they run in node: under jsdom the
3
+ // Astro plugin compiles `.astro` imports for the browser (client stub).
4
+ import { expect, test } from 'vitest';
5
+ import { renderComponent } from '../testUtils/index.js';
6
+ import Illuminator from './Illuminator.astro';
7
+
8
+ test('Should render children inside the illuminator shell', async () => {
9
+ const html = await renderComponent(Illuminator, {
10
+ slots: { default: '<span>hello</span>' },
11
+ });
12
+
13
+ expect(html).toContain('data-elyndra-illuminator');
14
+ expect(html).toContain('hello');
15
+ });
16
+
17
+ test('Should pass serializable settings to the client script', async () => {
18
+ const html = await renderComponent(Illuminator, {
19
+ props: { color: 'red', size: 120 },
20
+ slots: { default: 'x' },
21
+ });
22
+
23
+ expect(html).toContain('data-elyndra-illuminator');
24
+ expect(html).toContain('initIlluminatorElement');
25
+ });
@@ -0,0 +1,26 @@
1
+ ---
2
+ import type { IlluminatorSVGElementProps } from '../types.js';
3
+
4
+ interface Props extends IlluminatorSVGElementProps {}
5
+
6
+ const { className, ...settings } = Astro.props as Props;
7
+
8
+ // Ids only need page-level uniqueness: the component script consumes them in
9
+ // the same render. The `<g>` must be placed inside a consumer `<svg>`; the
10
+ // client resolves it via `closest('svg')`.
11
+ const nodeId = `elyndra-illuminator-svg-${Math.random().toString(36).slice(2)}`;
12
+ const serializable = { ...settings };
13
+ ---
14
+
15
+ <g id={nodeId} data-elyndra-illuminator-svg class={className} style="pointer-events: none;">
16
+ <slot />
17
+ </g>
18
+
19
+ <script define:vars={{ nodeId, serializable }}>
20
+ import { initIlluminatorSVGElement } from '../client/effectsClient.js';
21
+
22
+ const element = document.getElementById(nodeId);
23
+ if (element instanceof SVGGElement) {
24
+ initIlluminatorSVGElement(element, serializable);
25
+ }
26
+ </script>
@@ -0,0 +1,16 @@
1
+ // @vitest-environment node
2
+ // Render tests assert on SSR strings, so they run in node: under jsdom the
3
+ // Astro plugin compiles `.astro` imports for the browser (client stub).
4
+ import { expect, test } from 'vitest';
5
+ import { renderComponent } from '../testUtils/index.js';
6
+ import IlluminatorSVG from './IlluminatorSVG.astro';
7
+
8
+ test('Should render children inside the svg illuminator shell', async () => {
9
+ const html = await renderComponent(IlluminatorSVG, {
10
+ props: { color: 'red' },
11
+ slots: { default: '<circle />' },
12
+ });
13
+
14
+ expect(html).toContain('data-elyndra-illuminator-svg');
15
+ expect(html).toContain('initIlluminatorSVGElement');
16
+ });
package/src/astro.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ // In-repo ambient types for `.astro` imports (tests only). The published
2
+ // package ships the `.astro` sources and consumers get full prop inference
3
+ // from the frontmatter `Props` interfaces via their own Astro setup.
4
+ declare module '*.astro' {
5
+ const Component: unknown;
6
+ export default Component;
7
+ }
@@ -0,0 +1,52 @@
1
+ import { beforeEach, expect, test } from 'vitest';
2
+ import { initIlluminatorElement, initIlluminatorSVGElement } from './effectsClient.js';
3
+
4
+ beforeEach(() => {
5
+ document.body.innerHTML = '';
6
+ });
7
+
8
+ test('Should append the illuminator layer and mark the node', () => {
9
+ const element = document.createElement('div');
10
+ document.body.appendChild(element);
11
+
12
+ initIlluminatorElement(element, { color: 'red', size: 120 });
13
+
14
+ expect(element.hasAttribute('data-elyndra-illuminator-node')).toBe(true);
15
+ expect(element.querySelector('[data-name="illuminator"]')).not.toBeNull();
16
+ });
17
+
18
+ test('Should remove the layer and unmark on dispose, idempotently', () => {
19
+ const element = document.createElement('div');
20
+ document.body.appendChild(element);
21
+ const handle = initIlluminatorElement(element, {});
22
+
23
+ handle.dispose();
24
+ handle.dispose();
25
+
26
+ expect(element.hasAttribute('data-elyndra-illuminator-node')).toBe(false);
27
+ expect(element.querySelector('[data-name="illuminator"]')).toBeNull();
28
+ });
29
+
30
+ test('Should resolve the svg ancestor and mark the svg node', () => {
31
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
32
+ const element = document.createElementNS('http://www.w3.org/2000/svg', 'g');
33
+ svg.appendChild(element);
34
+ document.body.appendChild(svg);
35
+
36
+ const handle = initIlluminatorSVGElement(element, { color: 'blue', size: 80 });
37
+
38
+ expect(handle).toBeDefined();
39
+ expect(element.hasAttribute('data-elyndra-illuminator-svg-node')).toBe(true);
40
+
41
+ handle?.dispose();
42
+
43
+ expect(element.hasAttribute('data-elyndra-illuminator-svg-node')).toBe(false);
44
+ });
45
+
46
+ test('Should return undefined outside any svg', () => {
47
+ const element = document.createElementNS('http://www.w3.org/2000/svg', 'g');
48
+ document.body.appendChild(element);
49
+
50
+ expect(initIlluminatorSVGElement(element, {})).toBeUndefined();
51
+ expect(element.hasAttribute('data-elyndra-illuminator-svg-node')).toBe(false);
52
+ });
@@ -0,0 +1,108 @@
1
+ import {
2
+ type EffectIlluminator,
3
+ type EffectIlluminatorSVG,
4
+ createEffectIlluminator,
5
+ createEffectIlluminatorSVG,
6
+ } from '@elyndra/effects';
7
+ import { filterProps } from '@elyndra/tools';
8
+ import type { IlluminatorElementProps, IlluminatorSVGElementProps } from '../types.js';
9
+
10
+ const ILLUMINATOR_NODE_MARKER = 'data-elyndra-illuminator-node';
11
+ const ILLUMINATOR_SVG_NODE_MARKER = 'data-elyndra-illuminator-svg-node';
12
+
13
+ interface IlluminatorHandle {
14
+ readonly effect: EffectIlluminator;
15
+ readonly dispose: () => void;
16
+ }
17
+
18
+ interface IlluminatorSVGHandle {
19
+ readonly effect: EffectIlluminatorSVG;
20
+ readonly dispose: () => void;
21
+ }
22
+
23
+ const illuminatorByElement = new WeakMap<Element, IlluminatorHandle>();
24
+ const illuminatorSVGByElement = new WeakMap<Element, IlluminatorSVGHandle>();
25
+
26
+ /**
27
+ * Hydrates one illuminator element: the shell itself is the vanilla
28
+ * container. Idempotent: re-running cancels the previous effect first.
29
+ */
30
+ const initIlluminatorElement = (
31
+ element: HTMLElement,
32
+ props: IlluminatorElementProps,
33
+ ): IlluminatorHandle => {
34
+ illuminatorByElement.get(element)?.dispose();
35
+
36
+ const effect = createEffectIlluminator({
37
+ container: element,
38
+ ...filterProps({ color: props.color, size: props.size }),
39
+ });
40
+
41
+ element.setAttribute(ILLUMINATOR_NODE_MARKER, '');
42
+
43
+ const handle: IlluminatorHandle = {
44
+ effect,
45
+ dispose: () => {
46
+ if (illuminatorByElement.get(element) !== handle) {
47
+ return;
48
+ }
49
+ illuminatorByElement.delete(element);
50
+ element.removeAttribute(ILLUMINATOR_NODE_MARKER);
51
+ effect.cancel();
52
+ },
53
+ };
54
+
55
+ illuminatorByElement.set(element, handle);
56
+
57
+ return handle;
58
+ };
59
+
60
+ /**
61
+ * Hydrates one SVG illuminator element: the `<svg>` ancestor resolves via
62
+ * `closest('svg')` (the island replacement for the React `svgRef`). Returns
63
+ * `undefined` outside any `<svg>`. Idempotent: re-running cancels the
64
+ * previous effect first.
65
+ */
66
+ const initIlluminatorSVGElement = (
67
+ element: SVGGElement,
68
+ props: IlluminatorSVGElementProps,
69
+ ): IlluminatorSVGHandle | undefined => {
70
+ illuminatorSVGByElement.get(element)?.dispose();
71
+
72
+ const svg = element.closest('svg');
73
+ if (!(svg instanceof SVGSVGElement)) {
74
+ return undefined;
75
+ }
76
+
77
+ const effect = createEffectIlluminatorSVG({
78
+ svg,
79
+ container: element,
80
+ ...filterProps({ color: props.color, size: props.size }),
81
+ });
82
+
83
+ element.setAttribute(ILLUMINATOR_SVG_NODE_MARKER, '');
84
+
85
+ const handle: IlluminatorSVGHandle = {
86
+ effect,
87
+ dispose: () => {
88
+ if (illuminatorSVGByElement.get(element) !== handle) {
89
+ return;
90
+ }
91
+ illuminatorSVGByElement.delete(element);
92
+ element.removeAttribute(ILLUMINATOR_SVG_NODE_MARKER);
93
+ effect.cancel();
94
+ },
95
+ };
96
+
97
+ illuminatorSVGByElement.set(element, handle);
98
+
99
+ return handle;
100
+ };
101
+
102
+ export {
103
+ ILLUMINATOR_NODE_MARKER,
104
+ ILLUMINATOR_SVG_NODE_MARKER,
105
+ initIlluminatorElement,
106
+ initIlluminatorSVGElement,
107
+ };
108
+ export type { IlluminatorHandle, IlluminatorSVGHandle };
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './client/effectsClient.js';
2
+ export type * from './types.js';
@@ -0,0 +1 @@
1
+ export * from './renderComponent.js';
@@ -0,0 +1,16 @@
1
+ import {
2
+ type ContainerRenderOptions,
3
+ experimental_AstroContainer as AstroContainer,
4
+ } from 'astro/container';
5
+
6
+ /**
7
+ * Renders an `.astro` component to a string. In-repo `.astro` imports are
8
+ * `unknown` (ambient declaration, tests only), so the factory type is
9
+ * inferred from the container itself — no drift if Astro changes it.
10
+ */
11
+ const renderComponent = async (component: unknown, options?: ContainerRenderOptions): Promise<string> => {
12
+ const container = await AstroContainer.create();
13
+ return container.renderToString(component as Parameters<typeof container.renderToString>[0], options);
14
+ };
15
+
16
+ export { renderComponent };
package/src/types.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Serializable subset of the illuminator settings. Only JSON crosses the
3
+ * Astro server/client boundary (`define:vars`):
4
+ * - `container` is excluded: the shell element itself is the container.
5
+ * - `className`/`style` stay server-side as presentational shell attrs.
6
+ * - `elementRef`/`svgRef` are excluded: the shell element is hydrated
7
+ * directly and the `<svg>` ancestor resolves via `closest('svg')`.
8
+ */
9
+ interface IlluminatorSerializableSettings {
10
+ color?: string;
11
+ size?: number;
12
+ }
13
+
14
+ interface IlluminatorElementProps extends IlluminatorSerializableSettings {
15
+ className?: string;
16
+ }
17
+
18
+ interface IlluminatorSVGSerializableSettings {
19
+ color?: string;
20
+ size?: number;
21
+ }
22
+
23
+ interface IlluminatorSVGElementProps extends IlluminatorSVGSerializableSettings {
24
+ className?: string;
25
+ }
26
+
27
+ export type {
28
+ IlluminatorElementProps,
29
+ IlluminatorSerializableSettings,
30
+ IlluminatorSVGElementProps,
31
+ IlluminatorSVGSerializableSettings,
32
+ };