@elyndra/astro-text 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-text",
3
+ "version": "1.2.0",
4
+ "sideEffects": false,
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "description": "Elyndra text 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
+ "./Text": "./src/Text/Text.astro",
35
+ "./client": "./src/client/textClient.ts"
36
+ },
37
+ "types": "./src/index.ts",
38
+ "module": "./src/index.ts",
39
+ "main": "./src/index.ts",
40
+ "peerDependencies": {
41
+ "astro": "7.3.2"
42
+ },
43
+ "dependencies": {
44
+ "@elyndra/animated": "^1.2.0",
45
+ "@elyndra/text": "^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,25 @@
1
+ ---
2
+ import type { TextElementProps } from '../types.js';
3
+
4
+ interface Props extends TextElementProps {}
5
+
6
+ const { as: Tag = 'p', className, contentClassName, ...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-text-${Math.random().toString(36).slice(2)}`;
11
+ const serializable = { ...settings };
12
+ ---
13
+
14
+ <Tag id={nodeId} data-elyndra-text class={className} style="position: relative;">
15
+ <span data-elyndra-text-content class={contentClassName}><slot /></span>
16
+ </Tag>
17
+
18
+ <script define:vars={{ nodeId, serializable }}>
19
+ import { initTextElement } from '../client/textClient.js';
20
+
21
+ const element = document.getElementById(nodeId);
22
+ if (element instanceof HTMLElement) {
23
+ initTextElement(element, serializable);
24
+ }
25
+ </script>
@@ -0,0 +1,26 @@
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 Text from './Text.astro';
7
+
8
+ test('Should render children inside the text shell', async () => {
9
+ const html = await renderComponent(Text, {
10
+ slots: { default: 'hello' },
11
+ });
12
+
13
+ expect(html).toContain('data-elyndra-text');
14
+ expect(html).toContain('data-elyndra-text-content');
15
+ expect(html).toContain('hello');
16
+ });
17
+
18
+ test('Should pass serializable settings to the client script', async () => {
19
+ const html = await renderComponent(Text, {
20
+ props: { manager: 'decipher', blink: false },
21
+ slots: { default: 'x' },
22
+ });
23
+
24
+ expect(html).toContain('data-elyndra-text');
25
+ expect(html).toContain('initTextElement');
26
+ });
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,54 @@
1
+ import { beforeEach, expect, test } from 'vitest';
2
+ import { initTextElement } from './textClient.js';
3
+
4
+ beforeEach(() => {
5
+ document.body.innerHTML = '';
6
+ });
7
+
8
+ const mountText = (text = 'hello'): HTMLElement => {
9
+ const element = document.createElement('p');
10
+ element.setAttribute('data-elyndra-text', '');
11
+ const content = document.createElement('span');
12
+ content.setAttribute('data-elyndra-text-content', '');
13
+ content.textContent = text;
14
+ element.appendChild(content);
15
+ document.body.appendChild(element);
16
+ return element;
17
+ };
18
+
19
+ test('Should run the entering animation and mark the node', () => {
20
+ const element = mountText();
21
+
22
+ expect(() => initTextElement(element, { blink: false, duration: 0.05 })).not.toThrow();
23
+
24
+ expect(element.hasAttribute('data-elyndra-text-node')).toBe(true);
25
+ });
26
+
27
+ test('Should support the decipher manager', () => {
28
+ const element = mountText();
29
+
30
+ expect(() =>
31
+ initTextElement(element, { manager: 'decipher', blink: false, duration: 0.05 }),
32
+ ).not.toThrow();
33
+
34
+ expect(element.hasAttribute('data-elyndra-text-node')).toBe(true);
35
+ });
36
+
37
+ test('Should cancel and unmark on dispose, idempotently', () => {
38
+ const element = mountText();
39
+ const handle = initTextElement(element, { blink: false, duration: 0.05 });
40
+
41
+ handle.dispose();
42
+ handle.dispose();
43
+
44
+ expect(element.hasAttribute('data-elyndra-text-node')).toBe(false);
45
+ });
46
+
47
+ test('Should cancel the previous animation on second init', () => {
48
+ const element = mountText();
49
+
50
+ initTextElement(element, { blink: false, duration: 0.05 });
51
+ initTextElement(element, { blink: false, duration: 0.05 });
52
+
53
+ expect(element.hasAttribute('data-elyndra-text-node')).toBe(true);
54
+ });
@@ -0,0 +1,75 @@
1
+ import type { Animation } from '@elyndra/animated';
2
+ import {
3
+ animateTextDecipher,
4
+ animateTextSequence,
5
+ getAnimationTextDuration,
6
+ } from '@elyndra/text';
7
+ import { filterProps } from '@elyndra/tools';
8
+ import type { TextElementProps } from '../types.js';
9
+
10
+ const TEXT_NODE_MARKER = 'data-elyndra-text-node';
11
+ const TEXT_CONTENT_SELECTOR = '[data-elyndra-text-content]';
12
+
13
+ interface TextHandle {
14
+ readonly dispose: () => void;
15
+ }
16
+
17
+ const textByElement = new WeakMap<Element, TextHandle>();
18
+
19
+ /**
20
+ * Hydrates one text element: runs the entering animation once from the
21
+ * serializable settings (the island replacement for the animator
22
+ * subscription: no animator states to follow). Idempotent: re-running
23
+ * cancels the previous animation first.
24
+ */
25
+ const initTextElement = (element: HTMLElement, props: TextElementProps): TextHandle => {
26
+ textByElement.get(element)?.dispose();
27
+
28
+ const contentElement = element.querySelector(TEXT_CONTENT_SELECTOR);
29
+
30
+ let animation: Animation | undefined;
31
+
32
+ if (contentElement instanceof HTMLElement) {
33
+ const length = contentElement.textContent?.length ?? 0;
34
+ const duration = getAnimationTextDuration({ length, maxDuration: props.duration ?? 1 });
35
+ const baseOptions = {
36
+ rootElement: element,
37
+ contentElement,
38
+ duration,
39
+ isEntering: true,
40
+ ...filterProps({
41
+ easing: typeof props.easing === 'string' ? props.easing : undefined,
42
+ hideOnExited: props.hideOnExited,
43
+ hideOnEntered: props.hideOnEntered,
44
+ }),
45
+ };
46
+
47
+ animation =
48
+ props.manager === 'decipher'
49
+ ? animateTextDecipher({ ...baseOptions, ...filterProps({ characters: props.characters }) })
50
+ : animateTextSequence({
51
+ ...baseOptions,
52
+ ...filterProps({ blink: props.blink, blinkDuration: props.blinkDuration }),
53
+ });
54
+ }
55
+
56
+ element.setAttribute(TEXT_NODE_MARKER, '');
57
+
58
+ const handle: TextHandle = {
59
+ dispose: () => {
60
+ if (textByElement.get(element) !== handle) {
61
+ return;
62
+ }
63
+ textByElement.delete(element);
64
+ element.removeAttribute(TEXT_NODE_MARKER);
65
+ animation?.cancel();
66
+ },
67
+ };
68
+
69
+ textByElement.set(element, handle);
70
+
71
+ return handle;
72
+ };
73
+
74
+ export { initTextElement, TEXT_CONTENT_SELECTOR, TEXT_NODE_MARKER };
75
+ export type { TextHandle };
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './client/textClient.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,35 @@
1
+ import type { Easing } from '@elyndra/animated';
2
+ import type { AnimateTextManager } from '@elyndra/text';
3
+
4
+ /**
5
+ * Serializable subset of the text settings. Only JSON crosses the Astro
6
+ * server/client boundary (`define:vars`):
7
+ * - `easing` crosses as a name: easing functions are excluded.
8
+ * - `duration` is a max: the client derives the effective duration from the
9
+ * content length via `getAnimationTextDuration` (the React non-fixed mode).
10
+ * - Excluded: `manager` instances (the `manager` prop selects the vanilla
11
+ * `sequence`/`decipher` runner by name instead), `elementRef`,
12
+ * `children` (default slot), the Animator subscription (`useAnimator`):
13
+ * islands run the entering animation once on hydration instead of
14
+ * following animator states, and `fixed` (no animator to fix from).
15
+ * - `Text` exposes no transition callbacks in React either, so no
16
+ * CustomEvent replacement is needed.
17
+ */
18
+ interface TextSerializableSettings {
19
+ manager?: AnimateTextManager;
20
+ easing?: Easing;
21
+ duration?: number;
22
+ blink?: boolean;
23
+ blinkDuration?: number;
24
+ characters?: string;
25
+ hideOnEntered?: boolean;
26
+ hideOnExited?: boolean;
27
+ }
28
+
29
+ interface TextElementProps extends TextSerializableSettings {
30
+ as?: string;
31
+ className?: string;
32
+ contentClassName?: string;
33
+ }
34
+
35
+ export type { TextElementProps, TextSerializableSettings };