@rimelight/ui 0.0.26 → 0.0.28

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimelight/ui",
3
- "version": "0.0.26",
3
+ "version": "0.0.28",
4
4
  "private": false,
5
5
  "description": "Rimelight Entertainment UI Library.",
6
6
  "keywords": [
@@ -56,6 +56,12 @@
56
56
  "publishConfig": {
57
57
  "access": "public"
58
58
  },
59
+ "scripts": {
60
+ "prepare": "vp config",
61
+ "knip": "knip",
62
+ "astro": "astro",
63
+ "docs:meta": "tsx scripts/extract-meta.ts"
64
+ },
59
65
  "dependencies": {
60
66
  "@astrojs/prism": "^4.0.1",
61
67
  "@astrojs/vue": "6.0.1",
@@ -97,9 +103,5 @@
97
103
  "optional": true
98
104
  }
99
105
  },
100
- "scripts": {
101
- "knip": "knip",
102
- "astro": "astro",
103
- "docs:meta": "tsx scripts/extract-meta.ts"
104
- }
105
- }
106
+ "packageManager": "pnpm@11.1.2"
107
+ }
@@ -1,104 +1,108 @@
1
- ---
2
- import { scv, cx } from "css-variants"
3
- import { useUi, resolveClasses } from "../../utils"
4
- import type { ComponentVariants, ComponentSlots } from "../../types"
5
- import buttonTheme from "../../themes/button.theme"
6
-
7
- const button = scv(buttonTheme)
8
- type ButtonVariants = ComponentVariants<typeof buttonTheme>
9
-
10
- export interface RLAButtonProps {
11
- variant?: ButtonVariants['variant'];
12
- color?: ButtonVariants['color'];
13
- size?: ButtonVariants['size'];
14
- href?: string;
15
- label?: string;
16
- leadingIcon?: string;
17
- trailingIcon?: string;
18
- loading?: boolean;
19
- square?: boolean;
20
- block?: boolean;
21
- active?: boolean;
22
- activeColor?: ButtonVariants['color'];
23
- activeVariant?: ButtonVariants['variant'];
24
- ui?: ComponentSlots<typeof buttonTheme>;
25
- class?: any;
26
- [key: string]: unknown;
27
- }
28
-
29
- const {
30
- variant = "solid",
31
- color = "primary",
32
- size = "md",
33
- href,
34
- label,
35
- leadingIcon,
36
- trailingIcon,
37
- loading = false,
38
- square = false,
39
- block = false,
40
- active = false,
41
- activeColor,
42
- activeVariant,
43
- ui: uiProp,
44
- class: className,
45
- ...rest
46
- } = Astro.props as RLAButtonProps
47
-
48
- const isLeading = !!(leadingIcon || Astro.slots.has("leading")) || loading
49
- const isTrailing = !!(trailingIcon || Astro.slots.has("trailing")) || (loading && !isLeading)
50
-
51
- const showLeadingIcon = loading
52
- ? "i-lucide-loader-circle"
53
- : leadingIcon
54
-
55
- const showTrailingIcon = loading
56
- ? "i-lucide-loader-circle"
57
- : trailingIcon
58
-
59
- const leadingIconClass = loading ? "animate-spin" : ""
60
- const trailingIconClass = loading ? "animate-spin" : ""
61
-
62
- const classes = resolveClasses(button, {
63
- variant: active && activeVariant ? activeVariant : variant,
64
- color: active && activeColor ? activeColor : color,
65
- size,
66
- block,
67
- square: square || (!label && !Astro.slots.has("default")),
68
- leading: isLeading,
69
- trailing: isTrailing,
70
- loading,
71
- active
72
- }, useUi("button", uiProp), className);
73
-
74
- const Tag = href ? "a" : "button";
75
- ---
76
- <Tag
77
- class={classes.root}
78
- data-slot="root"
79
- {...(Tag === "a" ? { href } : {})}
80
- {...rest}
81
- >
82
- <slot name="leading" {...{ ui: classes }}>
83
- {isLeading && (
84
- <span class={classes.leadingIcon} data-slot="leading-icon">
85
- <span class={cx(showLeadingIcon, leadingIconClass)} aria-hidden="true" />
86
- </span>
87
- )}
88
- </slot>
89
-
90
- {label !== undefined && label !== null && (
91
- <span class={classes.label} data-slot="label">
92
- {label}
93
- </span>
94
- )}
95
- {!label && <slot />}
96
-
97
- <slot name="trailing" {...{ ui: classes }}>
98
- {isTrailing && (
99
- <span class={classes.trailingIcon} data-slot="trailing-icon">
100
- <span class={cx(showTrailingIcon, trailingIconClass)} aria-hidden="true" />
101
- </span>
102
- )}
103
- </slot>
104
- </Tag>
1
+ ---
2
+ import { scv, cx } from "css-variants"
3
+ import { useUi, resolveClasses } from "../../utils"
4
+ import type { ComponentVariants, ComponentSlots } from "../../types"
5
+ import buttonThemeDefault, { createButtonTheme } from "../../themes/button.theme"
6
+ import { getUIConfig } from "virtual:rimelight-ui"
7
+
8
+ const uiConfig = getUIConfig()
9
+ const buttonTheme = uiConfig.colors ? createButtonTheme(uiConfig.colors) : buttonThemeDefault
10
+
11
+ const button = scv(buttonTheme)
12
+ type ButtonVariants = ComponentVariants<typeof buttonThemeDefault>
13
+
14
+ export interface RLAButtonProps {
15
+ variant?: ButtonVariants['variant'];
16
+ color?: ButtonVariants['color'];
17
+ size?: ButtonVariants['size'];
18
+ href?: string;
19
+ label?: string;
20
+ leadingIcon?: string;
21
+ trailingIcon?: string;
22
+ loading?: boolean;
23
+ square?: boolean;
24
+ block?: boolean;
25
+ active?: boolean;
26
+ activeColor?: ButtonVariants['color'];
27
+ activeVariant?: ButtonVariants['variant'];
28
+ ui?: ComponentSlots<typeof buttonTheme>;
29
+ class?: any;
30
+ [key: string]: unknown;
31
+ }
32
+
33
+ const {
34
+ variant = "solid",
35
+ color = "primary",
36
+ size = "md",
37
+ href,
38
+ label,
39
+ leadingIcon,
40
+ trailingIcon,
41
+ loading = false,
42
+ square = false,
43
+ block = false,
44
+ active = false,
45
+ activeColor,
46
+ activeVariant,
47
+ ui: uiProp,
48
+ class: className,
49
+ ...rest
50
+ } = Astro.props as RLAButtonProps
51
+
52
+ const isLeading = !!(leadingIcon || Astro.slots.has("leading")) || loading
53
+ const isTrailing = !!(trailingIcon || Astro.slots.has("trailing")) || (loading && !isLeading)
54
+
55
+ const showLeadingIcon = loading
56
+ ? "i-lucide-loader-circle"
57
+ : leadingIcon
58
+
59
+ const showTrailingIcon = loading
60
+ ? "i-lucide-loader-circle"
61
+ : trailingIcon
62
+
63
+ const leadingIconClass = loading ? "animate-spin" : ""
64
+ const trailingIconClass = loading ? "animate-spin" : ""
65
+
66
+ const classes = resolveClasses(button, {
67
+ variant: active && activeVariant ? activeVariant : variant,
68
+ color: active && activeColor ? activeColor : color,
69
+ size,
70
+ block,
71
+ square: square || (!label && !Astro.slots.has("default")),
72
+ leading: isLeading,
73
+ trailing: isTrailing,
74
+ loading,
75
+ active
76
+ }, useUi("button", uiProp), className);
77
+
78
+ const Tag = href ? "a" : "button";
79
+ ---
80
+ <Tag
81
+ class={classes.root}
82
+ data-slot="root"
83
+ {...(Tag === "a" ? { href } : {})}
84
+ {...rest}
85
+ >
86
+ <slot name="leading" {...{ ui: classes }}>
87
+ {isLeading && (
88
+ <span class={classes.leadingIcon} data-slot="leading-icon">
89
+ <span class={cx(showLeadingIcon, leadingIconClass)} aria-hidden="true" />
90
+ </span>
91
+ )}
92
+ </slot>
93
+
94
+ {label !== undefined && label !== null && (
95
+ <span class={classes.label} data-slot="label">
96
+ {label}
97
+ </span>
98
+ )}
99
+ {!label && <slot />}
100
+
101
+ <slot name="trailing" {...{ ui: classes }}>
102
+ {isTrailing && (
103
+ <span class={classes.trailingIcon} data-slot="trailing-icon">
104
+ <span class={cx(showTrailingIcon, trailingIconClass)} aria-hidden="true" />
105
+ </span>
106
+ )}
107
+ </slot>
108
+ </Tag>
@@ -0,0 +1,219 @@
1
+ ---
2
+ import { scv } from "css-variants"
3
+ import { useUi, resolveClasses } from "../../utils"
4
+ import type { ComponentVariants, ComponentSlots } from "../../types"
5
+ import embedYoutubeTheme from "../../themes/embedYoutube.theme"
6
+ import type { RLAIconProps } from "./RLAIcon.astro"
7
+ import { Image } from "astro:assets"
8
+
9
+ const PLACEHOLDER_THUMBNAIL = "/assets/placeholder.webp";
10
+ const YOUTUBE_EMBED_BASE = "https://www.youtube.com/embed/";
11
+ const VALID_ID_PATTERN = /^[\w-]{11}$/;
12
+ const KNOWN_PREFIXES = ["www.", "m.", "music.", "gaming."];
13
+ const YOUTUBE_THUMB_BASE = "https://i3.ytimg.com/vi/";
14
+ const MAXRES_THUMBNAIL = "maxresdefault.jpg";
15
+ const DEFAULT_THUMBNAIL = "hqdefault.jpg";
16
+
17
+ const thumbnailAvailabilityCache = new Map<string, boolean>();
18
+
19
+ function normalizeHost(host: string): string {
20
+ for (const prefix of KNOWN_PREFIXES) {
21
+ if (host.startsWith(prefix)) {
22
+ return host.slice(prefix.length);
23
+ }
24
+ }
25
+ return host;
26
+ }
27
+
28
+ function sanitizeVideoId(id: string | null): string | null {
29
+ if (!id) return null;
30
+ return VALID_ID_PATTERN.test(id) ? id : null;
31
+ }
32
+
33
+ function extractYoutubeVideoId(rawUrl: string): string | null {
34
+ if (!rawUrl) return null;
35
+
36
+ try {
37
+ const url = new URL(rawUrl);
38
+ const host = normalizeHost(url.hostname);
39
+
40
+ if (host === "youtu.be") {
41
+ const id = url.pathname.split("/").filter(Boolean)[0] ?? null;
42
+ return sanitizeVideoId(id);
43
+ }
44
+
45
+ if (host.endsWith("youtube.com")) {
46
+ const paramsId = sanitizeVideoId(url.searchParams.get("v"));
47
+ if (paramsId) return paramsId;
48
+
49
+ const segments = url.pathname.split("/").filter(Boolean);
50
+ if (!segments.length) return null;
51
+
52
+ if (segments[0] === "embed" || segments[0] === "shorts" || segments[0] === "live") {
53
+ return sanitizeVideoId(segments[1] ?? null);
54
+ }
55
+
56
+ return sanitizeVideoId(segments[segments.length - 1] ?? null);
57
+ }
58
+ } catch {
59
+ // If it's just an 11-character ID passed directly, return it validated
60
+ if (VALID_ID_PATTERN.test(rawUrl)) {
61
+ return rawUrl;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ return null;
67
+ }
68
+
69
+ async function isThumbnailAvailable(url: string): Promise<boolean> {
70
+ if (thumbnailAvailabilityCache.has(url)) {
71
+ return thumbnailAvailabilityCache.get(url)!;
72
+ }
73
+
74
+ if (typeof fetch !== "function") {
75
+ return false;
76
+ }
77
+
78
+ try {
79
+ const response = await fetch(url, { method: "HEAD" });
80
+ let available = response.ok;
81
+
82
+ if (!available && response.status === 405) {
83
+ const getResponse = await fetch(url, { method: "GET" });
84
+ available = getResponse.ok;
85
+ }
86
+
87
+ thumbnailAvailabilityCache.set(url, available);
88
+ return available;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ async function getYoutubeThumbnail(rawUrl: string): Promise<string> {
95
+ const id = extractYoutubeVideoId(rawUrl);
96
+ if (!id) return PLACEHOLDER_THUMBNAIL;
97
+
98
+ const baseUrl = `${YOUTUBE_THUMB_BASE}${id}`;
99
+ const maxResUrl = `${baseUrl}/${MAXRES_THUMBNAIL}`;
100
+
101
+ if (await isThumbnailAvailable(maxResUrl)) {
102
+ return maxResUrl;
103
+ }
104
+
105
+ return `${baseUrl}/${DEFAULT_THUMBNAIL}`;
106
+ }
107
+
108
+ function getYoutubeEmbedUrl(rawUrl: string): string | null {
109
+ const id = extractYoutubeVideoId(rawUrl);
110
+ return id ? `${YOUTUBE_EMBED_BASE}${id}?autoplay=1` : null;
111
+ }
112
+
113
+ const embedYoutube = scv(embedYoutubeTheme)
114
+ type EmbedYoutubeVariants = ComponentVariants<typeof embedYoutubeTheme>
115
+
116
+ export interface RLAEmbedYoutubeProps {
117
+ /**
118
+ * The URL of the YouTube video to embed.
119
+ */
120
+ src: string;
121
+ /**
122
+ * The title of the video to display.
123
+ * @default "YouTube Video Player"
124
+ */
125
+ title?: string;
126
+ /**
127
+ * The name of the icon to display in the play trigger overlay.
128
+ * @default "i-lucide-play"
129
+ */
130
+ icon?: RLAIconProps["name"];
131
+ ui?: ComponentSlots<typeof embedYoutubeTheme>;
132
+ class?: any;
133
+ [key: string]: unknown;
134
+ }
135
+
136
+ const {
137
+ src,
138
+ title = "YouTube Video Player",
139
+ icon = "i-lucide-play",
140
+ ui: uiProp,
141
+ class: className,
142
+ ...rest
143
+ } = Astro.props as RLAEmbedYoutubeProps;
144
+
145
+ const classes = resolveClasses(embedYoutube, {
146
+ title: !!(Astro.slots.has("title") || Astro.props.title),
147
+ icon: !!(Astro.slots.has("icon") || Astro.props.icon),
148
+ }, useUi("embedYoutube", uiProp), className);
149
+
150
+ const embedUrl = getYoutubeEmbedUrl(src);
151
+ const thumbnail = await getYoutubeThumbnail(src);
152
+ const isPlayable = Boolean(embedUrl);
153
+ const ariaLabel = isPlayable ? "Play YouTube Video" : "YouTube Video Unavailable";
154
+ ---
155
+
156
+ <div
157
+ class:list={[classes.root]}
158
+ {...rest}
159
+ >
160
+ <button
161
+ type="button"
162
+ data-youtube-trigger
163
+ aria-label={ariaLabel}
164
+ data-embed-url={isPlayable ? embedUrl : undefined}
165
+ disabled={!isPlayable}
166
+ data-video-title={title}
167
+ class:list={[classes.trigger]}
168
+ >
169
+ <Image
170
+ layout="full-width"
171
+ src={thumbnail}
172
+ alt={title}
173
+ width="1280"
174
+ height="720"
175
+ loading="lazy"
176
+ class:list={[classes.image]}
177
+ />
178
+
179
+ <slot name="icon">
180
+ <span class:list={[classes.icon, icon]} aria-hidden="true" />
181
+ </slot>
182
+ </button>
183
+ </div>
184
+
185
+ <script is:inline>
186
+ (() => {
187
+ const root = document.currentScript?.previousElementSibling;
188
+ if (!(root instanceof HTMLElement)) return;
189
+
190
+ const trigger = root.querySelector("[data-youtube-trigger]");
191
+ if (!(trigger instanceof HTMLButtonElement)) return;
192
+
193
+ const embedUrl = trigger.dataset.embedUrl;
194
+ const title = trigger.dataset.videoTitle;
195
+ if (!embedUrl) return;
196
+
197
+ const activate = () => {
198
+ const iframe = document.createElement("iframe");
199
+ iframe.src = embedUrl;
200
+ iframe.title = title;
201
+ iframe.allow =
202
+ "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share";
203
+ iframe.allowFullscreen = true;
204
+ iframe.loading = "lazy";
205
+ iframe.referrerPolicy = "strict-origin-when-cross-origin";
206
+ iframe.classList.add("w-full", "h-full", "absolute", "inset-0");
207
+
208
+ root.replaceChildren(iframe);
209
+ };
210
+
211
+ trigger.addEventListener("click", activate, { once: true });
212
+ trigger.addEventListener("keydown", (event) => {
213
+ if (event.key === "Enter" || event.key === " ") {
214
+ event.preventDefault();
215
+ activate();
216
+ }
217
+ }, { once: true });
218
+ })();
219
+ </script>
@@ -104,23 +104,19 @@ const TitleTag = variant === "hero" ? "h1" : "h2";
104
104
  <slot name="top" />
105
105
 
106
106
  <div
107
- data-slot="container"
108
107
  class:list={[classes.container]}
109
108
  >
110
109
  {(Astro.slots.has("header") || Astro.props.icon || Astro.props.headline || Astro.props.title || Astro.props.description || Astro.slots.has("body") || Astro.slots.has("footer") || Astro.slots.has("links") || Astro.props.links?.length) && (
111
110
  <div
112
- data-slot="wrapper"
113
111
  class:list={[classes.wrapper]}
114
112
  >
115
113
  {(Astro.slots.has("header") || Astro.props.icon || Astro.props.headline || Astro.props.title || Astro.props.description) && (
116
114
  <div
117
- data-slot="header"
118
115
  class:list={[classes.header]}
119
116
  >
120
117
  <slot name="header">
121
118
  {(Astro.props.icon || Astro.slots.has("leading")) && (
122
119
  <div
123
- data-slot="leading"
124
120
  class:list={[classes.leading]}
125
121
  >
126
122
  <slot name="leading" {...{ ui: classes }}>
@@ -133,7 +129,6 @@ const TitleTag = variant === "hero" ? "h1" : "h2";
133
129
 
134
130
  {(Astro.props.headline || Astro.slots.has("headline")) && (
135
131
  <div
136
- data-slot="headline"
137
132
  class:list={[classes.headline]}
138
133
  >
139
134
  <slot name="headline">
@@ -144,7 +139,6 @@ const TitleTag = variant === "hero" ? "h1" : "h2";
144
139
 
145
140
  {(Astro.props.title || Astro.slots.has("title")) && (
146
141
  <TitleTag
147
- data-slot="title"
148
142
  class:list={[classes.title]}
149
143
  >
150
144
  <slot name="title">
@@ -155,7 +149,6 @@ const TitleTag = variant === "hero" ? "h1" : "h2";
155
149
 
156
150
  {(Astro.props.description || Astro.slots.has("description")) && (
157
151
  <div
158
- data-slot="description"
159
152
  class:list={[classes.description]}
160
153
  >
161
154
  <slot name="description">
@@ -169,7 +162,6 @@ const TitleTag = variant === "hero" ? "h1" : "h2";
169
162
 
170
163
  {Astro.slots.has("body") && (
171
164
  <div
172
- data-slot="body"
173
165
  class:list={[classes.body]}
174
166
  >
175
167
  <slot name="body" />
@@ -178,13 +170,11 @@ const TitleTag = variant === "hero" ? "h1" : "h2";
178
170
 
179
171
  {(Astro.slots.has("footer") || Astro.slots.has("links") || Astro.props.links?.length) && (
180
172
  <div
181
- data-slot="footer"
182
173
  class:list={[classes.footer]}
183
174
  >
184
175
  <slot name="footer">
185
176
  {(Astro.props.links?.length || Astro.slots.has("links")) && (
186
177
  <div
187
- data-slot="links"
188
178
  class:list={[classes.links]}
189
179
  >
190
180
  {Astro.props.links?.map((link) => (
package/src/env.d.ts CHANGED
@@ -9,7 +9,7 @@ declare module "virtual:nuxt-ui-plugins" {
9
9
  declare module "virtual:rimelight-ui" {
10
10
  import type { DefaultUIConfig } from "../themes"
11
11
 
12
- export function getUIConfig(): DefaultUIConfig
12
+ export function getUIConfig(): DefaultUIConfig & { colors?: string[] }
13
13
  }
14
14
 
15
15
  interface StarlightAddonsConfig {
@@ -33,6 +33,10 @@ export interface UIOptions {
33
33
  * wins).
34
34
  */
35
35
  ui?: UIConfigOverrides
36
+ /**
37
+ * Custom color names to dynamically generate variants for.
38
+ */
39
+ colors?: string[]
36
40
  }
37
41
 
38
42
  /**
@@ -59,7 +63,9 @@ export interface UIOptions {
59
63
  */
60
64
  export function ui(options: UIOptions = {}): AstroIntegration[] {
61
65
  const resolvedUI = defu(options.ui ?? {}, defaultUIConfig)
62
- const resolvedConfig = { ...resolvedUI, logos: options.logos ?? {} }
66
+ const coreColors = ["primary", "secondary", "info", "success", "warning", "error", "commentary", "ideation", "source"]
67
+ const mergedColors = [...new Set([...coreColors, ...(options.colors || [])])]
68
+ const resolvedConfig = { ...resolvedUI, logos: options.logos ?? {}, colors: mergedColors }
63
69
 
64
70
  const virtualModuleId = "virtual:rimelight-ui"
65
71
  const resolvedVirtualModuleId = "\0" + virtualModuleId