@inferencesh/ui 0.0.0-stage → 0.1.1

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 (165) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +68 -2
  3. package/components.json +22 -0
  4. package/package.json +231 -4
  5. package/src/components/audio-player.tsx +549 -0
  6. package/src/components/button.tsx +13 -0
  7. package/src/components/chart.tsx +403 -0
  8. package/src/components/circuit.tsx +276 -0
  9. package/src/components/code-block/code-block.tsx +260 -0
  10. package/src/components/code-block/index.ts +18 -0
  11. package/src/components/code-block/languages.ts +311 -0
  12. package/src/components/code-block/styles.ts +39 -0
  13. package/src/components/code-block/tokenizer.ts +290 -0
  14. package/src/components/code-block/types.ts +38 -0
  15. package/src/components/code-block/utils.ts +39 -0
  16. package/src/components/code.tsx +48 -0
  17. package/src/components/cookie-consent.tsx +137 -0
  18. package/src/components/copy-button.tsx +44 -0
  19. package/src/components/country-dropdown.tsx +188 -0
  20. package/src/components/glow-loader-fast.tsx +32 -0
  21. package/src/components/glow-loader-mono.tsx +33 -0
  22. package/src/components/glow-loader-sm-fast.tsx +32 -0
  23. package/src/components/glow-loader-sm-mono.tsx +34 -0
  24. package/src/components/glow-loader-sm.tsx +32 -0
  25. package/src/components/glow-loader.tsx +32 -0
  26. package/src/components/glow-logo.tsx +32 -0
  27. package/src/components/gradient.tsx +80 -0
  28. package/src/components/index.ts +45 -0
  29. package/src/components/inline-sidebar.tsx +351 -0
  30. package/src/components/input-color.tsx +349 -0
  31. package/src/components/logo-base.tsx +213 -0
  32. package/src/components/logo-color.tsx +23 -0
  33. package/src/components/logo-full.tsx +71 -0
  34. package/src/components/logo-svg.tsx +23 -0
  35. package/src/components/menu-navigation.tsx +359 -0
  36. package/src/components/multi-select.tsx +721 -0
  37. package/src/components/pastel-gradient.tsx +25 -0
  38. package/src/components/pixel-symmetry.tsx +151 -0
  39. package/src/components/progress.tsx +28 -0
  40. package/src/components/scroll-more.tsx +131 -0
  41. package/src/components/select.tsx +85 -0
  42. package/src/components/shimmering-text.tsx +108 -0
  43. package/src/components/sidebar-light.tsx +102 -0
  44. package/src/components/sonner.tsx +38 -0
  45. package/src/components/theme-switcher-sidebar.tsx +56 -0
  46. package/src/components/theme-switcher-simple.tsx +110 -0
  47. package/src/components/theme-switcher.tsx +47 -0
  48. package/src/components/toggle-group.tsx +55 -0
  49. package/src/components/tooltip-button.tsx +91 -0
  50. package/src/components/tooltip.tsx +71 -0
  51. package/src/components/tree-view.tsx +499 -0
  52. package/src/components/unglow-loader.tsx +33 -0
  53. package/src/components/waveform.tsx +1632 -0
  54. package/src/components/zoomable-image.tsx +76 -0
  55. package/src/docs/api-docs.tsx +763 -0
  56. package/src/docs/callout.tsx +160 -0
  57. package/src/docs/code-group.tsx +215 -0
  58. package/src/docs/copy-menu.tsx +143 -0
  59. package/src/docs/doc-tabs.tsx +84 -0
  60. package/src/docs/index.ts +16 -0
  61. package/src/docs/steps.tsx +105 -0
  62. package/src/docs/table-of-contents.tsx +174 -0
  63. package/src/examples/guide-markdown.ts +129 -0
  64. package/src/examples/index.ts +1153 -0
  65. package/src/global.d.ts +1 -0
  66. package/src/hooks/use-copy-to-clipboard.ts +36 -0
  67. package/src/hooks/use-mobile.ts +19 -0
  68. package/src/lib/display-name.ts +15 -0
  69. package/src/lib/example-input.ts +130 -0
  70. package/src/lib/plans.ts +44 -0
  71. package/src/lib/safe-content.ts +137 -0
  72. package/src/lib/schema.ts +320 -0
  73. package/src/lib/storage.ts +108 -0
  74. package/src/markdown/docs-markdown-renderer.tsx +518 -0
  75. package/src/markdown/helpers.ts +95 -0
  76. package/src/markdown/index.ts +24 -0
  77. package/src/markdown/markdown-renderer.tsx +232 -0
  78. package/src/markdown/youtube-embed.tsx +43 -0
  79. package/src/og/image.ts +312 -0
  80. package/src/og/index.tsx +430 -0
  81. package/src/og/query.ts +26 -0
  82. package/src/shadcn/accordion.tsx +71 -0
  83. package/src/shadcn/alert-dialog.tsx +187 -0
  84. package/src/shadcn/alert.tsx +75 -0
  85. package/src/shadcn/aspect-ratio.tsx +22 -0
  86. package/src/shadcn/attachment.tsx +207 -0
  87. package/src/shadcn/avatar.tsx +106 -0
  88. package/src/shadcn/badge.tsx +51 -0
  89. package/src/shadcn/breadcrumb.tsx +124 -0
  90. package/src/shadcn/bubble.tsx +127 -0
  91. package/src/shadcn/button-group.tsx +87 -0
  92. package/src/shadcn/button.tsx +57 -0
  93. package/src/shadcn/calendar.tsx +219 -0
  94. package/src/shadcn/card.tsx +102 -0
  95. package/src/shadcn/carousel.tsx +242 -0
  96. package/src/shadcn/chart.tsx +372 -0
  97. package/src/shadcn/checkbox.tsx +28 -0
  98. package/src/shadcn/collapsible.tsx +19 -0
  99. package/src/shadcn/combobox.tsx +297 -0
  100. package/src/shadcn/command.tsx +194 -0
  101. package/src/shadcn/context-menu.tsx +270 -0
  102. package/src/shadcn/dialog.tsx +158 -0
  103. package/src/shadcn/direction.tsx +4 -0
  104. package/src/shadcn/drawer.tsx +227 -0
  105. package/src/shadcn/dropdown-menu.tsx +265 -0
  106. package/src/shadcn/empty.tsx +103 -0
  107. package/src/shadcn/field.tsx +238 -0
  108. package/src/shadcn/form.tsx +173 -0
  109. package/src/shadcn/hover-card.tsx +50 -0
  110. package/src/shadcn/index.ts +53 -0
  111. package/src/shadcn/input-group.tsx +156 -0
  112. package/src/shadcn/input-otp.tsx +84 -0
  113. package/src/shadcn/input.tsx +19 -0
  114. package/src/shadcn/item.tsx +201 -0
  115. package/src/shadcn/kbd.tsx +26 -0
  116. package/src/shadcn/label.tsx +19 -0
  117. package/src/shadcn/marker.tsx +70 -0
  118. package/src/shadcn/menubar.tsx +278 -0
  119. package/src/shadcn/message-scroller.tsx +131 -0
  120. package/src/shadcn/message.tsx +91 -0
  121. package/src/shadcn/native-select.tsx +60 -0
  122. package/src/shadcn/navigation-menu.tsx +167 -0
  123. package/src/shadcn/pagination.tsx +130 -0
  124. package/src/shadcn/popover.tsx +87 -0
  125. package/src/shadcn/progress.tsx +82 -0
  126. package/src/shadcn/radio-group.tsx +35 -0
  127. package/src/shadcn/resizable.tsx +49 -0
  128. package/src/shadcn/scroll-area.tsx +52 -0
  129. package/src/shadcn/select.tsx +200 -0
  130. package/src/shadcn/separator.tsx +22 -0
  131. package/src/shadcn/sheet.tsx +136 -0
  132. package/src/shadcn/sidebar.tsx +721 -0
  133. package/src/shadcn/skeleton.tsx +13 -0
  134. package/src/shadcn/slider.tsx +51 -0
  135. package/src/shadcn/sonner.tsx +49 -0
  136. package/src/shadcn/spinner.tsx +10 -0
  137. package/src/shadcn/switch.tsx +29 -0
  138. package/src/shadcn/table.tsx +115 -0
  139. package/src/shadcn/tabs.tsx +79 -0
  140. package/src/shadcn/textarea.tsx +17 -0
  141. package/src/shadcn/toggle-group.tsx +89 -0
  142. package/src/shadcn/toggle.tsx +44 -0
  143. package/src/shadcn/tooltip.tsx +63 -0
  144. package/src/theme/config.ts +48 -0
  145. package/src/theme/css/base.css +92 -0
  146. package/src/theme/css/bitboy.css +75 -0
  147. package/src/theme/css/dark.css +47 -0
  148. package/src/theme/css/designer.css +122 -0
  149. package/src/theme/css/fonts.css +33 -0
  150. package/src/theme/css/globals.css +14 -0
  151. package/src/theme/css/hackintosh.css +57 -0
  152. package/src/theme/css/llama.css +103 -0
  153. package/src/theme/css/safelist.css +39 -0
  154. package/src/theme/css/shadcn.css +77 -0
  155. package/src/theme/css/theme.css +216 -0
  156. package/src/theme/embed-theme.ts +146 -0
  157. package/src/theme/fonts/FiraCode-VF.woff2 +0 -0
  158. package/src/theme/fonts/Hack-Regular.woff2 +0 -0
  159. package/src/theme/fonts/LICENSE-FiraCode.txt +93 -0
  160. package/src/theme/fonts/LICENSE-Hack.txt +45 -0
  161. package/src/theme/fonts/LICENSE-MonaSans.txt +93 -0
  162. package/src/theme/fonts/Mona-Sans.woff2 +0 -0
  163. package/src/theme/index.ts +15 -0
  164. package/src/utils/cn.ts +3 -0
  165. package/src/utils/index.ts +2 -0
@@ -0,0 +1 @@
1
+ declare module "*.css"
@@ -0,0 +1,36 @@
1
+ import { useCallback, useRef, useState } from "react"
2
+ import { toast } from "sonner"
3
+
4
+ type UseCopyToClipboardProps = {
5
+ text: string
6
+ copyMessage?: string
7
+ }
8
+
9
+ export function useCopyToClipboard({
10
+ text,
11
+ copyMessage = "Copied to clipboard!",
12
+ }: UseCopyToClipboardProps) {
13
+ const [isCopied, setIsCopied] = useState(false)
14
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
15
+
16
+ const handleCopy = useCallback(() => {
17
+ navigator.clipboard
18
+ .writeText(text)
19
+ .then(() => {
20
+ toast.success(copyMessage)
21
+ setIsCopied(true)
22
+ if (timeoutRef.current) {
23
+ clearTimeout(timeoutRef.current)
24
+ timeoutRef.current = null
25
+ }
26
+ timeoutRef.current = setTimeout(() => {
27
+ setIsCopied(false)
28
+ }, 2000)
29
+ })
30
+ .catch(() => {
31
+ toast.error("failed to copy to clipboard.")
32
+ })
33
+ }, [text, copyMessage])
34
+
35
+ return { isCopied, handleCopy }
36
+ }
@@ -0,0 +1,19 @@
1
+ import * as React from "react"
2
+
3
+ const MOBILE_BREAKPOINT = 768
4
+
5
+ export function useIsMobile() {
6
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
7
+
8
+ React.useEffect(() => {
9
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10
+ const onChange = () => {
11
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12
+ }
13
+ mql.addEventListener("change", onChange)
14
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15
+ return () => mql.removeEventListener("change", onChange)
16
+ }, [])
17
+
18
+ return !!isMobile
19
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Named resources — apps, agents, flows, knowledge, MCP servers, publications —
3
+ * carry both a `name`, the slug that addresses them (`veo-3-1`), and an
4
+ * optional `title`, the name a person reads (`Veo 3.1`).
5
+ *
6
+ * The slug belongs in URLs, refs and code samples. The title belongs in
7
+ * headings, cards, page titles and prose. Not every resource has a title,
8
+ * so this falls back to the slug.
9
+ */
10
+ export function displayName(
11
+ resource: { title?: string; name?: string } | null | undefined,
12
+ fallback = "",
13
+ ): string {
14
+ return resource?.title?.trim() || resource?.name?.trim() || fallback
15
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * An example input for an app, used by every code sample that runs it.
3
+ *
4
+ * The app's own example comes first: a top-level `examples` entry on its input
5
+ * schema (pydantic's `json_schema_extra={"examples": [...]}`). Without one, the
6
+ * input is built from the schema: required fields, fields with an example, and
7
+ * optional lists of objects that carry no default (the question lists of the
8
+ * decision apps, where the app needs at least one entry). Values come from each
9
+ * field's example, then its default, then a placeholder.
10
+ */
11
+
12
+ import type { Schema } from './schema'
13
+
14
+ type Json = unknown
15
+
16
+ const MAX_DEPTH = 6
17
+
18
+ function first(value: Json): Json {
19
+ return Array.isArray(value) ? value[0] : value
20
+ }
21
+
22
+ function isObject(value: Json): value is Record<string, unknown> {
23
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
24
+ }
25
+
26
+ function deref(schema: Schema, root: Schema): Schema {
27
+ if (!schema?.$ref) return schema
28
+ const name = schema.$ref.replace('#/$defs/', '').replace('#/definitions/', '')
29
+ const target = root.$defs?.[name] ?? root.definitions?.[name]
30
+ if (!target) return schema
31
+ const { $ref: _ref, ...rest } = schema
32
+ return { ...deref(target, root), ...rest }
33
+ }
34
+
35
+ /** The non-null branch of `anyOf [T, null]` or `T | null`, else the schema itself. */
36
+ function concrete(schema: Schema, root: Schema): Schema {
37
+ const s = deref(schema, root)
38
+ const branches = s.anyOf ?? s.oneOf
39
+ if (branches?.length) {
40
+ const branch = branches.find((b) => b.type !== 'null') ?? branches[0]
41
+ const { anyOf: _a, oneOf: _o, ...rest } = s
42
+ return concrete({ ...rest, ...(branch as Schema) }, root)
43
+ }
44
+ if (Array.isArray(s.type)) {
45
+ return { ...s, type: s.type.find((t) => t !== 'null') ?? s.type[0] }
46
+ }
47
+ return s
48
+ }
49
+
50
+ function itemSchema(schema: Schema, root: Schema): Schema | undefined {
51
+ const items = Array.isArray(schema.items) ? schema.items[0] : schema.items
52
+ return items ? concrete(items, root) : undefined
53
+ }
54
+
55
+ function hasExample(schema: Schema): boolean {
56
+ return first(schema.examples) !== undefined
57
+ }
58
+
59
+ /** An optional list of objects with required fields and no default: the app expects entries. */
60
+ function expectsEntries(schema: Schema, root: Schema): boolean {
61
+ if (schema.type !== 'array' || schema.default !== undefined) return false
62
+ const item = itemSchema(schema, root)
63
+ return item?.type === 'object' && (item.required?.length ?? 0) > 0
64
+ }
65
+
66
+ /** Where a value sits: its own key, and the list it is an entry of. */
67
+ interface Place {
68
+ key: string
69
+ list?: { key: string; index: number }
70
+ }
71
+
72
+ // Keys that name an entry, so entries of one list (and of sibling lists) stay distinct.
73
+ const NAME_KEYS = new Set(['id', 'name', 'key'])
74
+
75
+ function placeholder(place: Place): string {
76
+ if (place.list && NAME_KEYS.has(place.key)) return `${place.list.key}_${place.list.index + 1}`
77
+ return place.key
78
+ }
79
+
80
+ function value(raw: Schema, root: Schema, place: Place, depth: number): Json {
81
+ const schema = concrete(raw, root)
82
+ const example = first(schema.examples)
83
+ if (example !== undefined) return example
84
+ if (schema.default !== undefined && schema.default !== null && schema.default !== '') return schema.default
85
+ if (schema.const !== undefined) return schema.const
86
+ if (schema.enum?.length) return schema.enum[0]
87
+
88
+ switch (schema.type) {
89
+ case 'string':
90
+ if (schema.format === 'file' || schema.format === 'uri' || schema.format === 'url') return 'https://example.com/file.png'
91
+ return placeholder(place)
92
+ case 'integer':
93
+ case 'number':
94
+ return schema.minimum ?? 0
95
+ case 'boolean':
96
+ return false
97
+ case 'array': {
98
+ const item = itemSchema(schema, root)
99
+ if (!item || depth >= MAX_DEPTH) return []
100
+ const count = Math.max(schema.minItems ?? 1, 1)
101
+ return Array.from({ length: count }, (_, index) => {
102
+ if (item.type === 'object') return object(item, root, { key: place.key, index }, depth + 1)
103
+ return value(item, root, { key: `${place.key}_${index + 1}` }, depth + 1)
104
+ })
105
+ }
106
+ case 'object':
107
+ return depth >= MAX_DEPTH ? {} : object(schema, root, undefined, depth + 1)
108
+ default:
109
+ return placeholder(place)
110
+ }
111
+ }
112
+
113
+ function object(schema: Schema, root: Schema, list: Place['list'], depth: number): Record<string, unknown> {
114
+ const out: Record<string, unknown> = {}
115
+ const required = new Set(schema.required ?? [])
116
+ for (const [key, raw] of Object.entries(schema.properties ?? {})) {
117
+ const prop = concrete(raw, root)
118
+ if (required.has(key) || hasExample(prop) || expectsEntries(prop, root)) {
119
+ out[key] = value(raw, root, { key, list }, depth)
120
+ }
121
+ }
122
+ return out
123
+ }
124
+
125
+ export function exampleInput(schema?: Schema | null): Record<string, unknown> {
126
+ if (!schema) return {}
127
+ const own = first(schema.examples)
128
+ if (isObject(own)) return own
129
+ return object(schema, schema, undefined, 0)
130
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Reading a plan for display: its price, and the limits worth listing. Shared
3
+ * by the app's billing pages and the marketing site's pricing page.
4
+ */
5
+ import type { EntitlementResource, PlanLimit, PlanVersionDTO, SubscriptionInterval } from '@inferencesh/sdk'
6
+
7
+ type Priced = { active_version?: PlanVersionDTO }
8
+
9
+ /** A limit as plans carry it; every field optional so partial plan shapes fit. */
10
+ export type PlanLimitLike = Partial<PlanLimit>
11
+
12
+ export function getPriceAmount(plan: Priced, interval: SubscriptionInterval): number {
13
+ const v = plan.active_version
14
+ if (!v) return 0
15
+ return interval === 'yearly' ? (v.amount_yearly ?? 0) : (v.amount_monthly ?? 0)
16
+ }
17
+
18
+ /** The price per month: the monthly amount, or a twelfth of the yearly one. */
19
+ export function getMonthlyPrice(plan: Priced, interval: SubscriptionInterval): number {
20
+ if (interval === 'monthly') return getPriceAmount(plan, 'monthly')
21
+ return Math.round(getPriceAmount(plan, 'yearly') / 12)
22
+ }
23
+
24
+ export function hasPricing(plan: Priced): boolean {
25
+ const v = plan.active_version
26
+ return !!v && ((v.amount_monthly ?? 0) > 0 || (v.amount_yearly ?? 0) > 0)
27
+ }
28
+
29
+ /** Keys of the limits worth showing; every returned key is present in limits. */
30
+ export function getVisibleLimitKeys(
31
+ limits: { [key in EntitlementResource]?: PlanLimitLike } | undefined,
32
+ ): EntitlementResource[] {
33
+ return (Object.entries(limits || {}) as [EntitlementResource, PlanLimitLike][])
34
+ .filter(([, v]) => v.label && v.type === 'limit' && (v.unlimited || (v.limit != null && v.limit > 0)))
35
+ .map(([k]) => k)
36
+ }
37
+
38
+ /** Format a numeric limit value with its unit. Unit comes from backend. */
39
+ export function formatLimitValue(limit: PlanLimitLike): string {
40
+ if (limit.unlimited) return '∞'
41
+ if (!limit.limit) return '0'
42
+ if (limit.unit === 'mb' && limit.limit >= 1024) return `${Math.round(limit.limit / 1024)} gb`
43
+ return `${limit.limit}${limit.unit ? ` ${limit.unit}` : ''}`
44
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The one place user-supplied values are checked before they become a URL,
3
+ * a frame or a CSS value in the app (INF-1176, INC-2026-11-02).
4
+ *
5
+ * Text is safe as React renders it (escaped). What is not: a link or image
6
+ * address (javascript:, vbscript:, data:text/html), a frame that is given
7
+ * allow-same-origin while it loads from the app's own origin, and a value
8
+ * written into a <style> element. Every renderer of user content (the
9
+ * markdown renderers, the chat renderer, agent UI widgets, MCP cards, the
10
+ * artifact frame, charts) goes through these functions.
11
+ *
12
+ * Raw HTML in markdown is a separate rule: it is sanitised by
13
+ * rehype-sanitize (docs-markdown-renderer) or never parsed.
14
+ */
15
+
16
+ /** Schemes a link may use. Anything else, javascript: included, is dropped. */
17
+ const LINK_SCHEMES = new Set(["http:", "https:", "mailto:", "tel:"])
18
+
19
+ /** Schemes an image, video or audio source may use. */
20
+ const MEDIA_SCHEMES = new Set(["http:", "https:", "blob:"])
21
+
22
+ /** data: media types an image may carry inline (no SVG: it is a document). */
23
+ const DATA_IMAGE = /^data:image\/(png|jpe?g|gif|webp|avif|bmp|x-icon);base64,[a-z0-9+/=\s]*$/i
24
+
25
+ // ASCII control characters and whitespace a browser strips from a URL
26
+ // before reading its scheme ("java\tscript:", " javascript:").
27
+ // eslint-disable-next-line no-control-regex
28
+ const STRIPPED = /[\u0000-\u0020\u007f]/g
29
+
30
+ /**
31
+ * schemeOf is the lowercased scheme of url as a browser reads it, or "" for
32
+ * a relative reference (/path, #hash, ?q, path).
33
+ */
34
+ function schemeOf(url: string): string {
35
+ const s = url.replace(STRIPPED, "")
36
+ const m = /^([a-z][a-z0-9+.-]*):/i.exec(s)
37
+ if (!m) return ""
38
+ // "a/b:c" and "a?b:c" are relative: a scheme ends before any / ? #.
39
+ const colon = s.indexOf(":")
40
+ const firstDelim = s.search(/[/?#]/)
41
+ if (firstDelim !== -1 && firstDelim < colon) return ""
42
+ return m[1]!.toLowerCase() + ":"
43
+ }
44
+
45
+ /**
46
+ * safeUrl is url when it is a link target that runs nothing: http(s),
47
+ * mailto, tel or a relative reference. Otherwise undefined, so the caller
48
+ * renders no link.
49
+ */
50
+ export function safeUrl(url: unknown): string | undefined {
51
+ if (typeof url !== "string") return undefined
52
+ const trimmed = url.trim()
53
+ if (!trimmed) return undefined
54
+ const scheme = schemeOf(trimmed)
55
+ if (scheme === "") {
56
+ // Protocol-relative ("//host") is an absolute http(s) link: allowed.
57
+ return trimmed
58
+ }
59
+ return LINK_SCHEMES.has(scheme) ? trimmed : undefined
60
+ }
61
+
62
+ /**
63
+ * safeMediaSrc is src when it is an image/video/audio source: http(s),
64
+ * blob:, a relative reference or an inline raster data: image. Otherwise
65
+ * undefined.
66
+ */
67
+ export function safeMediaSrc(src: unknown): string | undefined {
68
+ if (typeof src !== "string") return undefined
69
+ const trimmed = src.trim()
70
+ if (!trimmed) return undefined
71
+ const scheme = schemeOf(trimmed)
72
+ if (scheme === "") return trimmed
73
+ if (scheme === "data:") return DATA_IMAGE.test(trimmed) ? trimmed : undefined
74
+ return MEDIA_SCHEMES.has(scheme) ? trimmed : undefined
75
+ }
76
+
77
+ /**
78
+ * safeUrlTransform is safeUrl as react-markdown's urlTransform: "" for a
79
+ * refused URL (react-markdown then drops the attribute's value). img src
80
+ * goes through safeMediaSrc.
81
+ */
82
+ export function safeUrlTransform(url: string, key: string): string {
83
+ if (key === "src") return safeMediaSrc(url) ?? ""
84
+ return safeUrl(url) ?? ""
85
+ }
86
+
87
+ /**
88
+ * isolatedFrameSrc is src when a frame may load it with allow-same-origin:
89
+ * an https (or, for local development, http) URL whose origin is not the
90
+ * host page's. A same-origin src with allow-scripts and allow-same-origin
91
+ * is the host page itself: the frame could remove its own sandbox. Returns
92
+ * undefined otherwise; the caller shows no frame.
93
+ */
94
+ export function isolatedFrameSrc(src: unknown, hostOrigin?: string): string | undefined {
95
+ if (typeof src !== "string" || !src) return undefined
96
+ let u: URL
97
+ try {
98
+ u = new URL(src)
99
+ } catch {
100
+ return undefined // relative: resolves to the host's origin
101
+ }
102
+ if (u.protocol !== "https:" && u.protocol !== "http:") return undefined
103
+ const host = hostOrigin ?? (typeof window !== "undefined" ? window.location.origin : undefined)
104
+ if (!host || u.origin === host) return undefined
105
+ return u.toString()
106
+ }
107
+
108
+ /** A CSS identifier we generate or accept from data (a series key, an id). */
109
+ const CSS_IDENT = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/
110
+
111
+ /** safeCssIdent is v when it is a plain identifier, else undefined. */
112
+ export function safeCssIdent(v: unknown): string | undefined {
113
+ return typeof v === "string" && CSS_IDENT.test(v) ? v : undefined
114
+ }
115
+
116
+ const CSS_COLOR = [
117
+ /^#[0-9a-f]{3,8}$/i,
118
+ /^[a-z]{3,20}$/i, // named colour
119
+ // rgb()/hsl()/oklch()/... and var(--x), with numbers, %, deg, commas,
120
+ // slashes and nested functions ("hsl(var(--chart-1) / 0.5)"). Quotes,
121
+ // url(), image-set(), semicolons and braces are refused before this.
122
+ /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix|var)\([a-z0-9\s.,%/+()-]*\)$/i,
123
+ ]
124
+
125
+ /**
126
+ * safeCssColor is v when it is a CSS colour value (hex, a name, a colour
127
+ * function or var(--x)), else undefined. A value from data written into a
128
+ * <style> element could otherwise close the rule and style the whole page
129
+ * (or load url()s that leak what is on it).
130
+ */
131
+ export function safeCssColor(v: unknown): string | undefined {
132
+ if (typeof v !== "string") return undefined
133
+ const s = v.trim()
134
+ if (!s || s.length > 100) return undefined
135
+ if (/url\(|image-set\(|[;{}<>"'\\@!]|expression|\/\*/i.test(s)) return undefined
136
+ return CSS_COLOR.some((re) => re.test(s)) ? s : undefined
137
+ }