@markdy/astro 0.1.0 → 0.1.2

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.
Files changed (2) hide show
  1. package/package.json +9 -5
  2. package/src/Markdy.astro +89 -8
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@markdy/astro",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Astro island component for MarkdyScript animations.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
+ "sideEffects": false,
7
8
  "files": [
8
9
  "src",
9
10
  "README.md",
@@ -17,7 +18,10 @@
17
18
  "astro",
18
19
  "animation",
19
20
  "island",
20
- "component"
21
+ "component",
22
+ "astro-animation",
23
+ "astro-component",
24
+ "declarative"
21
25
  ],
22
26
  "author": "Hoang Yell <hoangyell@gmail.com> (https://hoangyell.com)",
23
27
  "homepage": "https://markdy.com",
@@ -33,13 +37,13 @@
33
37
  "access": "public"
34
38
  },
35
39
  "dependencies": {
36
- "@markdy/renderer-dom": "0.1.0"
40
+ "@markdy/renderer-dom": "0.1.2"
37
41
  },
38
42
  "peerDependencies": {
39
43
  "astro": ">=4.0.0"
40
44
  },
41
45
  "devDependencies": {
42
- "astro": "^5.0.0",
43
- "typescript": "^5.4.0"
46
+ "astro": "^6.1.5",
47
+ "typescript": "^5.9.3"
44
48
  }
45
49
  }
package/src/Markdy.astro CHANGED
@@ -3,8 +3,13 @@
3
3
  * @markdy/astro — <Markdy /> island component
4
4
  *
5
5
  * SSR: renders a correctly-sized placeholder div so layout is stable.
6
+ * Exposes `title` / `description` for SEO and a `<noscript>` block
7
+ * so search-engine crawlers that do not execute JavaScript still
8
+ * index meaningful text.
6
9
  * Client: hydrates via IntersectionObserver when the element enters the
7
- * viewport (equivalent to client:visible behaviour).
10
+ * viewport. The renderer is dynamically imported so it is fully
11
+ * code-split from the host-page bundle and never blocks the
12
+ * critical rendering path.
8
13
  */
9
14
 
10
15
  export interface Props {
@@ -32,6 +37,17 @@ export interface Props {
32
37
  /** Start playing as soon as the scene is hydrated. Defaults to true. */
33
38
  autoplay?: boolean;
34
39
  class?: string;
40
+ /**
41
+ * Short human-readable title for the animation.
42
+ * Used as the accessible name (`aria-label`) and treated like `alt` on
43
+ * an image for SEO purposes.
44
+ */
45
+ title?: string;
46
+ /**
47
+ * Longer description rendered inside `<noscript>` so crawlers that do
48
+ * not run JavaScript still index meaningful content.
49
+ */
50
+ description?: string;
35
51
  }
36
52
 
37
53
  const {
@@ -42,6 +58,8 @@ const {
42
58
  assets = {},
43
59
  autoplay = true,
44
60
  class: className,
61
+ title = "Markdy animation",
62
+ description,
45
63
  } = Astro.props;
46
64
  ---
47
65
 
@@ -50,30 +68,54 @@ const {
50
68
  data-markdy-code={code}
51
69
  data-markdy-assets={JSON.stringify(assets)}
52
70
  data-markdy-autoplay={String(autoplay)}
71
+ role="img"
72
+ aria-label={title}
73
+ aria-busy="true"
53
74
  style={`width:${width}px;height:${height}px;overflow:hidden`}
54
75
  >
55
76
  <!--
56
77
  SSR placeholder: keeps layout stable before the island hydrates.
57
78
  Background matches the scene bg prop to avoid a visible flash.
79
+ aria-hidden because the parent role="img" aria-label carries the
80
+ accessible name — this inner text is decorative.
58
81
  -->
59
82
  <div
60
83
  class="markdy-placeholder"
61
- aria-label="Markdy animation loading"
84
+ aria-hidden="true"
62
85
  style={`width:${width}px;height:${height}px;background:${bg};display:flex;align-items:center;justify-content:center`}
63
86
  >
64
87
  <span
65
88
  style="font-family:sans-serif;font-size:12px;color:#bbb;letter-spacing:0.05em"
66
- aria-hidden="true"
67
89
  >
68
90
  ▶ markdy
69
91
  </span>
70
92
  </div>
93
+
94
+ <!--
95
+ noscript fallback: rendered for crawlers / users that do not run JS.
96
+ Provides indexable text content so the animation does not become an
97
+ opaque block in search results.
98
+ -->
99
+ <noscript>
100
+ <div
101
+ style={`width:${width}px;height:${height}px;background:${bg};display:flex;align-items:center;justify-content:center`}
102
+ >
103
+ <p
104
+ style="font-family:sans-serif;font-size:14px;color:#888;text-align:center;padding:1rem;margin:0"
105
+ >
106
+ {description ?? title}
107
+ </p>
108
+ </div>
109
+ </noscript>
71
110
  </div>
72
111
 
73
112
  <script>
74
- import { createPlayer } from "@markdy/renderer-dom";
113
+ // ── Hydration ────────────────────────────────────────────────────────────
114
+ // The renderer is dynamically imported so it is code-split from the
115
+ // host-page bundle. The browser only downloads it when an element is
116
+ // about to enter the viewport, keeping Time-to-Interactive low.
75
117
 
76
- function hydrate(el: HTMLElement): void {
118
+ async function hydrate(el: HTMLElement): Promise<void> {
77
119
  const code = el.dataset.markdyCode;
78
120
  if (!code) return;
79
121
 
@@ -89,23 +131,63 @@ const {
89
131
 
90
132
  const autoplay = el.dataset.markdyAutoplay !== "false";
91
133
 
92
- // Clear the SSR placeholder before mounting the canvas.
134
+ // Code-split: renderer-dom is NOT part of the initial page bundle.
135
+ const { createPlayer } = await import("@markdy/renderer-dom");
136
+
137
+ // Clear the SSR placeholder and mount the canvas atomically after the
138
+ // async import resolves, so there is no flash of empty content.
93
139
  el.innerHTML = "";
94
140
  el.style.overflow = "visible";
95
141
 
96
142
  createPlayer({ container: el, code, assets, autoplay });
143
+
144
+ el.dataset.markdyInit = "done";
145
+ el.removeAttribute("aria-busy");
146
+ }
147
+
148
+ // ── Task scheduling ──────────────────────────────────────────────────────
149
+ // Yield a macro-task slot before running heavy work so we never block
150
+ // user input or the frame pipeline.
151
+ // 1. Prioritized Task Scheduling API — Chrome 94+, Safari 17+
152
+ // 2. requestIdleCallback — broad support
153
+ // 3. setTimeout(0) — universal fallback
154
+
155
+ type SchedulerLike = {
156
+ postTask: (fn: () => void, opts: { priority: string }) => void;
157
+ };
158
+
159
+ function scheduleHydration(fn: () => void): void {
160
+ const sched = (globalThis as unknown as { scheduler?: SchedulerLike })
161
+ .scheduler;
162
+ if (sched?.postTask) {
163
+ sched.postTask(fn, { priority: "background" });
164
+ } else if (typeof requestIdleCallback !== "undefined") {
165
+ requestIdleCallback(fn, { timeout: 2000 });
166
+ } else {
167
+ setTimeout(fn, 0);
168
+ }
97
169
  }
98
170
 
171
+ // ── Intersection observer ────────────────────────────────────────────────
99
172
  // Observe every unhydrated .markdy-root element and hydrate it when it
100
173
  // enters the viewport (100 px root margin so the scene is ready before
101
174
  // the user actually sees it).
175
+
102
176
  const observer = new IntersectionObserver(
103
177
  (entries) => {
104
178
  for (const entry of entries) {
105
179
  if (entry.isIntersecting) {
106
180
  const el = entry.target as HTMLElement;
107
181
  observer.unobserve(el);
108
- hydrate(el);
182
+ // Mark immediately to prevent double-scheduling on re-runs.
183
+ el.dataset.markdyInit = "hydrating";
184
+ scheduleHydration(() => {
185
+ hydrate(el).catch(() => {
186
+ // On failure keep the placeholder visible — do not crash the page.
187
+ el.dataset.markdyInit = "error";
188
+ el.removeAttribute("aria-busy");
189
+ });
190
+ });
109
191
  }
110
192
  }
111
193
  },
@@ -116,7 +198,6 @@ const {
116
198
  document
117
199
  .querySelectorAll<HTMLElement>(".markdy-root:not([data-markdy-init])")
118
200
  .forEach((el) => {
119
- // Flag the element to prevent double-registration across re-runs.
120
201
  el.dataset.markdyInit = "pending";
121
202
  observer.observe(el);
122
203
  });