@rimelight/ui 0.0.2 → 0.0.4
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 +6 -3
- package/src/components/carousel/carousel-script.ts +188 -188
- package/src/components/carousel/index.ts +1 -1
- package/src/components/input-otp/InputOtpTypes.ts +6 -6
- package/src/components/radio-group/RadioGroupTypes.ts +6 -6
- package/src/components/select/SelectTypes.ts +13 -13
- package/src/components/switch/SwitchTypes.ts +6 -6
- package/src/components/toast/index.ts +1 -1
- package/src/components/toast/toast-manager.ts +234 -234
- package/src/components/toggle/ToggleTypes.ts +14 -14
- package/src/config/cookies.ts +5 -5
- package/src/config/index.ts +3 -3
- package/src/config/links.ts +4 -4
- package/src/config/site.config.ts +27 -45
- package/src/domain/cms/index.ts +1 -1
- package/src/domain/cms/types.ts +20 -20
- package/src/domain/i18n/constants.ts +21 -21
- package/src/domain/i18n/cookie-storage.ts +13 -13
- package/src/domain/i18n/country-to-locale-map.ts +67 -67
- package/src/domain/i18n/fetcher.ts +278 -268
- package/src/domain/i18n/flags.ts +16 -16
- package/src/domain/i18n/format.ts +90 -90
- package/src/domain/i18n/index.ts +10 -10
- package/src/domain/i18n/middleware/i18n.ts +161 -160
- package/src/domain/i18n/resolve-locale.ts +55 -55
- package/src/domain/i18n/schema.ts +29 -29
- package/src/domain/index.ts +4 -4
- package/src/domain/seo/constants.ts +6 -20
- package/src/domain/seo/index.ts +3 -3
- package/src/domain/seo/services/index.ts +3 -3
- package/src/domain/seo/services/llms.ts +56 -53
- package/src/domain/seo/services/robots.ts +12 -12
- package/src/domain/seo/services/sitemap.ts +56 -56
- package/src/domain/seo/utils.ts +36 -37
- package/src/domain/theme/constants.ts +6 -6
- package/src/domain/theme/index.ts +2 -2
- package/src/domain/theme/schema.ts +10 -10
- package/src/env.d.ts +49 -49
- package/src/integrations/index.ts +1 -0
- package/src/integrations/sri.ts +92 -0
- package/src/lib/component-preview.ts +73 -73
- package/src/lib/index.ts +6 -6
- package/src/lib/remark/remark-modified-time.ts +13 -13
- package/src/lib/showcase-preview-elements.ts +408 -408
- package/src/lib/showcase-renderer.ts +218 -218
- package/src/lib/showcase-utils.ts +189 -189
- package/src/lib/turnstile.ts +35 -35
- package/src/lib/utils/starwind/positioning.ts +318 -318
- package/src/locales/de/blog.json +9 -9
- package/src/locales/de/common.json +19 -19
- package/src/locales/de/errors.json +6 -6
- package/src/locales/de/home.json +20 -20
- package/src/locales/de/messages.json +8 -8
- package/src/locales/de/nav.json +13 -13
- package/src/locales/de/projects.json +9 -9
- package/src/locales/de/services.json +43 -43
- package/src/locales/en/blog.json +9 -9
- package/src/locales/en/chat.json +29 -29
- package/src/locales/en/common.json +19 -19
- package/src/locales/en/cookie_notice.json +11 -11
- package/src/locales/en/editor.json +24 -24
- package/src/locales/en/errors.json +6 -6
- package/src/locales/en/home.json +20 -20
- package/src/locales/en/messages.json +8 -8
- package/src/locales/en/nav.json +13 -13
- package/src/locales/en/projects.json +9 -9
- package/src/locales/en/services.json +43 -43
- package/src/locales/es/blog.json +9 -9
- package/src/locales/es/common.json +19 -19
- package/src/locales/es/errors.json +6 -6
- package/src/locales/es/home.json +20 -20
- package/src/locales/es/messages.json +8 -8
- package/src/locales/es/nav.json +13 -13
- package/src/locales/es/projects.json +9 -9
- package/src/locales/es/services.json +43 -43
- package/src/locales/ja/blog.json +9 -9
- package/src/locales/ja/common.json +19 -19
- package/src/locales/ja/errors.json +6 -6
- package/src/locales/ja/home.json +20 -20
- package/src/locales/ja/messages.json +8 -8
- package/src/locales/ja/nav.json +13 -13
- package/src/locales/ja/projects.json +9 -9
- package/src/locales/ja/services.json +43 -43
- package/src/locales/pt/blog.json +9 -9
- package/src/locales/pt/chat.json +29 -29
- package/src/locales/pt/common.json +19 -19
- package/src/locales/pt/cookie_notice.json +11 -11
- package/src/locales/pt/editor.json +24 -24
- package/src/locales/pt/errors.json +6 -6
- package/src/locales/pt/home.json +20 -20
- package/src/locales/pt/messages.json +8 -8
- package/src/locales/pt/nav.json +13 -13
- package/src/locales/pt/projects.json +9 -9
- package/src/locales/pt/services.json +43 -43
- package/src/middleware/index.ts +2 -0
- package/src/middleware/security.ts +15 -0
- package/src/middleware/sri.ts +78 -0
- package/src/utils/index.ts +2 -2
- package/src/utils/server/cookies.ts +98 -98
- package/src/utils/server/index.ts +1 -1
- package/src/utils/shared/deep-merge.ts +36 -36
- package/src/utils/shared/embed.ts +105 -105
- package/src/utils/shared/index.ts +3 -3
- package/src/utils/shared/parse-boolean.ts +14 -14
- package/worker-configuration.d.ts +0 -14791
|
@@ -1,98 +1,98 @@
|
|
|
1
|
-
import type { AstroCookies } from "astro"
|
|
2
|
-
import { z } from "astro/zod"
|
|
3
|
-
|
|
4
|
-
import { COOKIE_TTL } from "../../config"
|
|
5
|
-
|
|
6
|
-
type AstroCookieOptions = NonNullable<Parameters<AstroCookies["set"]>[2]>
|
|
7
|
-
|
|
8
|
-
const DEFAULT_OPTIONS: AstroCookieOptions = {
|
|
9
|
-
path: "/",
|
|
10
|
-
httpOnly: true,
|
|
11
|
-
secure: import.meta.env.PROD,
|
|
12
|
-
sameSite: "lax",
|
|
13
|
-
maxAge: COOKIE_TTL.oneYear
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export type SetCookieOverrides = Partial<AstroCookieOptions>
|
|
17
|
-
|
|
18
|
-
// ------------------------------
|
|
19
|
-
// SETTER
|
|
20
|
-
// ------------------------------
|
|
21
|
-
export function setCookie<T = string>(
|
|
22
|
-
cookies: AstroCookies,
|
|
23
|
-
name: string,
|
|
24
|
-
value: T,
|
|
25
|
-
overrides?: SetCookieOverrides
|
|
26
|
-
): void {
|
|
27
|
-
const stringValue = typeof value === "string" ? value : JSON.stringify(value)
|
|
28
|
-
|
|
29
|
-
cookies.set(name, stringValue, {
|
|
30
|
-
...DEFAULT_OPTIONS,
|
|
31
|
-
...overrides
|
|
32
|
-
})
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// ------------------------------
|
|
36
|
-
// STRING COOKIE WITH SCHEMA
|
|
37
|
-
// ------------------------------
|
|
38
|
-
export function getCookie(cookies: AstroCookies, name: string): string | null {
|
|
39
|
-
const entry = cookies.get(name)
|
|
40
|
-
if (!entry) return null
|
|
41
|
-
return entry.value
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// ------------------------------
|
|
45
|
-
// STRING COOKIE WITH SCHEMA
|
|
46
|
-
// ------------------------------
|
|
47
|
-
export function getCookieWithSchema<S extends z.ZodTypeAny>(
|
|
48
|
-
cookies: AstroCookies,
|
|
49
|
-
name: string,
|
|
50
|
-
schema: S
|
|
51
|
-
): z.infer<S> | null {
|
|
52
|
-
const entry = cookies.get(name)
|
|
53
|
-
if (!entry) return null
|
|
54
|
-
|
|
55
|
-
const raw = entry.value
|
|
56
|
-
|
|
57
|
-
const result = schema.safeParse(raw)
|
|
58
|
-
if (result.success) return result.data as z.infer<S>
|
|
59
|
-
|
|
60
|
-
if (!import.meta.env.PROD) {
|
|
61
|
-
console.warn(`Invalid cookie "${name}"`, z.treeifyError(result.error))
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return null
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ------------------------------
|
|
68
|
-
// JSON COOKIE
|
|
69
|
-
// ------------------------------
|
|
70
|
-
export function getJsonCookieWithSchema<S extends z.ZodTypeAny>(
|
|
71
|
-
cookies: AstroCookies,
|
|
72
|
-
name: string,
|
|
73
|
-
schema: S
|
|
74
|
-
): z.infer<S> | null {
|
|
75
|
-
const entry = cookies.get(name)
|
|
76
|
-
if (!entry) return null
|
|
77
|
-
|
|
78
|
-
const raw = entry.value
|
|
79
|
-
|
|
80
|
-
let parsed: unknown
|
|
81
|
-
try {
|
|
82
|
-
parsed = JSON.parse(raw)
|
|
83
|
-
} catch {
|
|
84
|
-
if (!import.meta.env.PROD) {
|
|
85
|
-
console.warn(`Failed to parse JSON cookie "${name}"`)
|
|
86
|
-
}
|
|
87
|
-
return null
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const result = schema.safeParse(parsed)
|
|
91
|
-
if (result.success) return result.data as z.infer<S>
|
|
92
|
-
|
|
93
|
-
if (!import.meta.env.PROD) {
|
|
94
|
-
console.warn(`Invalid JSON cookie "${name}"`, z.treeifyError(result.error))
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return null
|
|
98
|
-
}
|
|
1
|
+
import type { AstroCookies } from "astro"
|
|
2
|
+
import { z } from "astro/zod"
|
|
3
|
+
|
|
4
|
+
import { COOKIE_TTL } from "../../config"
|
|
5
|
+
|
|
6
|
+
type AstroCookieOptions = NonNullable<Parameters<AstroCookies["set"]>[2]>
|
|
7
|
+
|
|
8
|
+
const DEFAULT_OPTIONS: AstroCookieOptions = {
|
|
9
|
+
path: "/",
|
|
10
|
+
httpOnly: true,
|
|
11
|
+
secure: import.meta.env.PROD,
|
|
12
|
+
sameSite: "lax",
|
|
13
|
+
maxAge: COOKIE_TTL.oneYear
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type SetCookieOverrides = Partial<AstroCookieOptions>
|
|
17
|
+
|
|
18
|
+
// ------------------------------
|
|
19
|
+
// SETTER
|
|
20
|
+
// ------------------------------
|
|
21
|
+
export function setCookie<T = string>(
|
|
22
|
+
cookies: AstroCookies,
|
|
23
|
+
name: string,
|
|
24
|
+
value: T,
|
|
25
|
+
overrides?: SetCookieOverrides
|
|
26
|
+
): void {
|
|
27
|
+
const stringValue = typeof value === "string" ? value : JSON.stringify(value)
|
|
28
|
+
|
|
29
|
+
cookies.set(name, stringValue, {
|
|
30
|
+
...DEFAULT_OPTIONS,
|
|
31
|
+
...overrides
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ------------------------------
|
|
36
|
+
// STRING COOKIE WITH SCHEMA
|
|
37
|
+
// ------------------------------
|
|
38
|
+
export function getCookie(cookies: AstroCookies, name: string): string | null {
|
|
39
|
+
const entry = cookies.get(name)
|
|
40
|
+
if (!entry) return null
|
|
41
|
+
return entry.value
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ------------------------------
|
|
45
|
+
// STRING COOKIE WITH SCHEMA
|
|
46
|
+
// ------------------------------
|
|
47
|
+
export function getCookieWithSchema<S extends z.ZodTypeAny>(
|
|
48
|
+
cookies: AstroCookies,
|
|
49
|
+
name: string,
|
|
50
|
+
schema: S
|
|
51
|
+
): z.infer<S> | null {
|
|
52
|
+
const entry = cookies.get(name)
|
|
53
|
+
if (!entry) return null
|
|
54
|
+
|
|
55
|
+
const raw = entry.value
|
|
56
|
+
|
|
57
|
+
const result = schema.safeParse(raw)
|
|
58
|
+
if (result.success) return result.data as z.infer<S>
|
|
59
|
+
|
|
60
|
+
if (!import.meta.env.PROD) {
|
|
61
|
+
console.warn(`Invalid cookie "${name}"`, z.treeifyError(result.error))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ------------------------------
|
|
68
|
+
// JSON COOKIE
|
|
69
|
+
// ------------------------------
|
|
70
|
+
export function getJsonCookieWithSchema<S extends z.ZodTypeAny>(
|
|
71
|
+
cookies: AstroCookies,
|
|
72
|
+
name: string,
|
|
73
|
+
schema: S
|
|
74
|
+
): z.infer<S> | null {
|
|
75
|
+
const entry = cookies.get(name)
|
|
76
|
+
if (!entry) return null
|
|
77
|
+
|
|
78
|
+
const raw = entry.value
|
|
79
|
+
|
|
80
|
+
let parsed: unknown
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(raw)
|
|
83
|
+
} catch {
|
|
84
|
+
if (!import.meta.env.PROD) {
|
|
85
|
+
console.warn(`Failed to parse JSON cookie "${name}"`)
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const result = schema.safeParse(parsed)
|
|
91
|
+
if (result.success) return result.data as z.infer<S>
|
|
92
|
+
|
|
93
|
+
if (!import.meta.env.PROD) {
|
|
94
|
+
console.warn(`Invalid JSON cookie "${name}"`, z.treeifyError(result.error))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "./cookies"
|
|
1
|
+
export * from "./cookies"
|
|
@@ -1,36 +1,36 @@
|
|
|
1
|
-
function isObject(value: unknown): value is Record<string, unknown> {
|
|
2
|
-
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
3
|
-
}
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Deep-merge two dictionaries. - `base` provides known structure (fallback) - `overrides` can
|
|
7
|
-
* provide partial values - Both types are preserved and merged correctly
|
|
8
|
-
*/
|
|
9
|
-
function assertRecord(obj: unknown): asserts obj is Record<string, unknown> {
|
|
10
|
-
if (!obj || typeof obj !== "object") throw new Error("Expected object")
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function assertA<T>(obj: unknown): asserts obj is T {
|
|
14
|
-
if (!obj) throw new Error("Expected result")
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function deepMerge<A extends object, B extends Partial<A>>(base: A, overrides: B): A {
|
|
18
|
-
const result = { ...base }
|
|
19
|
-
assertRecord(result)
|
|
20
|
-
|
|
21
|
-
for (const key of Object.keys(overrides)) {
|
|
22
|
-
const overrideValue = Reflect.get(overrides, key)
|
|
23
|
-
const baseValue = Reflect.get(base, key)
|
|
24
|
-
|
|
25
|
-
if (overrideValue === undefined) continue
|
|
26
|
-
|
|
27
|
-
if (isObject(baseValue) && isObject(overrideValue)) {
|
|
28
|
-
Reflect.set(result, key, deepMerge(baseValue as object, overrideValue as Partial<object>))
|
|
29
|
-
} else {
|
|
30
|
-
Reflect.set(result, key, overrideValue)
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
assertA<A>(result)
|
|
35
|
-
return result
|
|
36
|
-
}
|
|
1
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Deep-merge two dictionaries. - `base` provides known structure (fallback) - `overrides` can
|
|
7
|
+
* provide partial values - Both types are preserved and merged correctly
|
|
8
|
+
*/
|
|
9
|
+
function assertRecord(obj: unknown): asserts obj is Record<string, unknown> {
|
|
10
|
+
if (!obj || typeof obj !== "object") throw new Error("Expected object")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function assertA<T>(obj: unknown): asserts obj is T {
|
|
14
|
+
if (!obj) throw new Error("Expected result")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function deepMerge<A extends object, B extends Partial<A>>(base: A, overrides: B): A {
|
|
18
|
+
const result = { ...base }
|
|
19
|
+
assertRecord(result)
|
|
20
|
+
|
|
21
|
+
for (const key of Object.keys(overrides)) {
|
|
22
|
+
const overrideValue = Reflect.get(overrides, key)
|
|
23
|
+
const baseValue = Reflect.get(base, key)
|
|
24
|
+
|
|
25
|
+
if (overrideValue === undefined) continue
|
|
26
|
+
|
|
27
|
+
if (isObject(baseValue) && isObject(overrideValue)) {
|
|
28
|
+
Reflect.set(result, key, deepMerge(baseValue as object, overrideValue as Partial<object>))
|
|
29
|
+
} else {
|
|
30
|
+
Reflect.set(result, key, overrideValue)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
assertA<A>(result)
|
|
35
|
+
return result
|
|
36
|
+
}
|
|
@@ -1,105 +1,105 @@
|
|
|
1
|
-
const PLACEHOLDER_THUMBNAIL = "/assets/placeholder.png"
|
|
2
|
-
|
|
3
|
-
const YOUTUBE_EMBED_BASE = "https://www.youtube.com/embed/"
|
|
4
|
-
|
|
5
|
-
const VALID_ID_PATTERN = /^[\w-]{11}$/
|
|
6
|
-
|
|
7
|
-
const KNOWN_PREFIXES = ["www.", "m.", "music.", "gaming."]
|
|
8
|
-
|
|
9
|
-
const YOUTUBE_THUMB_BASE = "https://i3.ytimg.com/vi/"
|
|
10
|
-
|
|
11
|
-
const MAXRES_THUMBNAIL = "maxresdefault.jpg"
|
|
12
|
-
|
|
13
|
-
const DEFAULT_THUMBNAIL = "hqdefault.jpg"
|
|
14
|
-
|
|
15
|
-
const thumbnailAvailabilityCache = new Map<string, boolean>()
|
|
16
|
-
|
|
17
|
-
function normalizeHost(host: string): string {
|
|
18
|
-
for (const prefix of KNOWN_PREFIXES) {
|
|
19
|
-
if (host.startsWith(prefix)) {
|
|
20
|
-
return host.slice(prefix.length)
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return host
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function sanitizeVideoId(id: string | null): string | null {
|
|
27
|
-
if (!id) return null
|
|
28
|
-
return VALID_ID_PATTERN.test(id) ? id : null
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function extractYoutubeVideoId(rawUrl: string): string | null {
|
|
32
|
-
if (!rawUrl) return null
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
const url = new URL(rawUrl)
|
|
36
|
-
const host = normalizeHost(url.hostname)
|
|
37
|
-
|
|
38
|
-
if (host === "youtu.be") {
|
|
39
|
-
const id = url.pathname.split("/").find(Boolean) ?? null
|
|
40
|
-
return sanitizeVideoId(id)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (host.endsWith("youtube.com")) {
|
|
44
|
-
const paramsId = sanitizeVideoId(url.searchParams.get("v"))
|
|
45
|
-
if (paramsId) return paramsId
|
|
46
|
-
|
|
47
|
-
const segments = url.pathname.split("/").filter(Boolean)
|
|
48
|
-
if (!segments.length) return null
|
|
49
|
-
|
|
50
|
-
if (segments[0] === "embed" || segments[0] === "shorts" || segments[0] === "live") {
|
|
51
|
-
return sanitizeVideoId(segments[1] ?? null)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
return sanitizeVideoId(segments[segments.length - 1] ?? null)
|
|
55
|
-
}
|
|
56
|
-
} catch {
|
|
57
|
-
return null
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return null
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function isThumbnailAvailable(url: string): Promise<boolean> {
|
|
64
|
-
if (thumbnailAvailabilityCache.has(url)) {
|
|
65
|
-
return thumbnailAvailabilityCache.get(url)!
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (typeof fetch !== "function") {
|
|
69
|
-
return false
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
const response = await fetch(url, { method: "HEAD" })
|
|
74
|
-
let available = response.ok
|
|
75
|
-
|
|
76
|
-
if (!available && response.status === 405) {
|
|
77
|
-
const getResponse = await fetch(url, { method: "GET" })
|
|
78
|
-
available = getResponse.ok
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
thumbnailAvailabilityCache.set(url, available)
|
|
82
|
-
return available
|
|
83
|
-
} catch {
|
|
84
|
-
return false
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export async function getYoutubeThumbnail(rawUrl: string): Promise<string> {
|
|
89
|
-
const id = extractYoutubeVideoId(rawUrl)
|
|
90
|
-
if (!id) return PLACEHOLDER_THUMBNAIL
|
|
91
|
-
|
|
92
|
-
const baseUrl = `${YOUTUBE_THUMB_BASE}${id}`
|
|
93
|
-
const maxResUrl = `${baseUrl}/${MAXRES_THUMBNAIL}`
|
|
94
|
-
|
|
95
|
-
if (await isThumbnailAvailable(maxResUrl)) {
|
|
96
|
-
return maxResUrl
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return `${baseUrl}/${DEFAULT_THUMBNAIL}`
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function getYoutubeEmbedUrl(rawUrl: string): string | null {
|
|
103
|
-
const id = extractYoutubeVideoId(rawUrl)
|
|
104
|
-
return id ? `${YOUTUBE_EMBED_BASE}${id}?autoplay=1` : null
|
|
105
|
-
}
|
|
1
|
+
const PLACEHOLDER_THUMBNAIL = "/assets/placeholder.png"
|
|
2
|
+
|
|
3
|
+
const YOUTUBE_EMBED_BASE = "https://www.youtube.com/embed/"
|
|
4
|
+
|
|
5
|
+
const VALID_ID_PATTERN = /^[\w-]{11}$/
|
|
6
|
+
|
|
7
|
+
const KNOWN_PREFIXES = ["www.", "m.", "music.", "gaming."]
|
|
8
|
+
|
|
9
|
+
const YOUTUBE_THUMB_BASE = "https://i3.ytimg.com/vi/"
|
|
10
|
+
|
|
11
|
+
const MAXRES_THUMBNAIL = "maxresdefault.jpg"
|
|
12
|
+
|
|
13
|
+
const DEFAULT_THUMBNAIL = "hqdefault.jpg"
|
|
14
|
+
|
|
15
|
+
const thumbnailAvailabilityCache = new Map<string, boolean>()
|
|
16
|
+
|
|
17
|
+
function normalizeHost(host: string): string {
|
|
18
|
+
for (const prefix of KNOWN_PREFIXES) {
|
|
19
|
+
if (host.startsWith(prefix)) {
|
|
20
|
+
return host.slice(prefix.length)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return host
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sanitizeVideoId(id: string | null): string | null {
|
|
27
|
+
if (!id) return null
|
|
28
|
+
return VALID_ID_PATTERN.test(id) ? id : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function extractYoutubeVideoId(rawUrl: string): string | null {
|
|
32
|
+
if (!rawUrl) return null
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const url = new URL(rawUrl)
|
|
36
|
+
const host = normalizeHost(url.hostname)
|
|
37
|
+
|
|
38
|
+
if (host === "youtu.be") {
|
|
39
|
+
const id = url.pathname.split("/").find(Boolean) ?? null
|
|
40
|
+
return sanitizeVideoId(id)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (host.endsWith("youtube.com")) {
|
|
44
|
+
const paramsId = sanitizeVideoId(url.searchParams.get("v"))
|
|
45
|
+
if (paramsId) return paramsId
|
|
46
|
+
|
|
47
|
+
const segments = url.pathname.split("/").filter(Boolean)
|
|
48
|
+
if (!segments.length) return null
|
|
49
|
+
|
|
50
|
+
if (segments[0] === "embed" || segments[0] === "shorts" || segments[0] === "live") {
|
|
51
|
+
return sanitizeVideoId(segments[1] ?? null)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return sanitizeVideoId(segments[segments.length - 1] ?? null)
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function isThumbnailAvailable(url: string): Promise<boolean> {
|
|
64
|
+
if (thumbnailAvailabilityCache.has(url)) {
|
|
65
|
+
return thumbnailAvailabilityCache.get(url)!
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (typeof fetch !== "function") {
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch(url, { method: "HEAD" })
|
|
74
|
+
let available = response.ok
|
|
75
|
+
|
|
76
|
+
if (!available && response.status === 405) {
|
|
77
|
+
const getResponse = await fetch(url, { method: "GET" })
|
|
78
|
+
available = getResponse.ok
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
thumbnailAvailabilityCache.set(url, available)
|
|
82
|
+
return available
|
|
83
|
+
} catch {
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function getYoutubeThumbnail(rawUrl: string): Promise<string> {
|
|
89
|
+
const id = extractYoutubeVideoId(rawUrl)
|
|
90
|
+
if (!id) return PLACEHOLDER_THUMBNAIL
|
|
91
|
+
|
|
92
|
+
const baseUrl = `${YOUTUBE_THUMB_BASE}${id}`
|
|
93
|
+
const maxResUrl = `${baseUrl}/${MAXRES_THUMBNAIL}`
|
|
94
|
+
|
|
95
|
+
if (await isThumbnailAvailable(maxResUrl)) {
|
|
96
|
+
return maxResUrl
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return `${baseUrl}/${DEFAULT_THUMBNAIL}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getYoutubeEmbedUrl(rawUrl: string): string | null {
|
|
103
|
+
const id = extractYoutubeVideoId(rawUrl)
|
|
104
|
+
return id ? `${YOUTUBE_EMBED_BASE}${id}?autoplay=1` : null
|
|
105
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export * from "./parse-boolean"
|
|
2
|
-
export * from "./deep-merge"
|
|
3
|
-
export * from "./embed"
|
|
1
|
+
export * from "./parse-boolean"
|
|
2
|
+
export * from "./deep-merge"
|
|
3
|
+
export * from "./embed"
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Parse a boolean flag from Wrangler-env string.
|
|
3
|
-
*
|
|
4
|
-
* Accepts: "1", "true", "on" → true "0", "false", "off" → false undefined → defaultValue
|
|
5
|
-
*/
|
|
6
|
-
export function parseBooleanFlag(value: string | undefined, defaultValue: boolean): boolean {
|
|
7
|
-
if (value === undefined) return defaultValue
|
|
8
|
-
|
|
9
|
-
const v = value.toLowerCase()
|
|
10
|
-
if (v === "1" || v === "true" || v === "on") return true
|
|
11
|
-
if (v === "0" || v === "false" || v === "off") return false
|
|
12
|
-
|
|
13
|
-
return defaultValue
|
|
14
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Parse a boolean flag from Wrangler-env string.
|
|
3
|
+
*
|
|
4
|
+
* Accepts: "1", "true", "on" → true "0", "false", "off" → false undefined → defaultValue
|
|
5
|
+
*/
|
|
6
|
+
export function parseBooleanFlag(value: string | undefined, defaultValue: boolean): boolean {
|
|
7
|
+
if (value === undefined) return defaultValue
|
|
8
|
+
|
|
9
|
+
const v = value.toLowerCase()
|
|
10
|
+
if (v === "1" || v === "true" || v === "on") return true
|
|
11
|
+
if (v === "0" || v === "false" || v === "off") return false
|
|
12
|
+
|
|
13
|
+
return defaultValue
|
|
14
|
+
}
|