@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.
- package/LICENSE +22 -0
- package/README.md +68 -2
- package/components.json +22 -0
- package/package.json +231 -4
- package/src/components/audio-player.tsx +549 -0
- package/src/components/button.tsx +13 -0
- package/src/components/chart.tsx +403 -0
- package/src/components/circuit.tsx +276 -0
- package/src/components/code-block/code-block.tsx +260 -0
- package/src/components/code-block/index.ts +18 -0
- package/src/components/code-block/languages.ts +311 -0
- package/src/components/code-block/styles.ts +39 -0
- package/src/components/code-block/tokenizer.ts +290 -0
- package/src/components/code-block/types.ts +38 -0
- package/src/components/code-block/utils.ts +39 -0
- package/src/components/code.tsx +48 -0
- package/src/components/cookie-consent.tsx +137 -0
- package/src/components/copy-button.tsx +44 -0
- package/src/components/country-dropdown.tsx +188 -0
- package/src/components/glow-loader-fast.tsx +32 -0
- package/src/components/glow-loader-mono.tsx +33 -0
- package/src/components/glow-loader-sm-fast.tsx +32 -0
- package/src/components/glow-loader-sm-mono.tsx +34 -0
- package/src/components/glow-loader-sm.tsx +32 -0
- package/src/components/glow-loader.tsx +32 -0
- package/src/components/glow-logo.tsx +32 -0
- package/src/components/gradient.tsx +80 -0
- package/src/components/index.ts +45 -0
- package/src/components/inline-sidebar.tsx +351 -0
- package/src/components/input-color.tsx +349 -0
- package/src/components/logo-base.tsx +213 -0
- package/src/components/logo-color.tsx +23 -0
- package/src/components/logo-full.tsx +71 -0
- package/src/components/logo-svg.tsx +23 -0
- package/src/components/menu-navigation.tsx +359 -0
- package/src/components/multi-select.tsx +721 -0
- package/src/components/pastel-gradient.tsx +25 -0
- package/src/components/pixel-symmetry.tsx +151 -0
- package/src/components/progress.tsx +28 -0
- package/src/components/scroll-more.tsx +131 -0
- package/src/components/select.tsx +85 -0
- package/src/components/shimmering-text.tsx +108 -0
- package/src/components/sidebar-light.tsx +102 -0
- package/src/components/sonner.tsx +38 -0
- package/src/components/theme-switcher-sidebar.tsx +56 -0
- package/src/components/theme-switcher-simple.tsx +110 -0
- package/src/components/theme-switcher.tsx +47 -0
- package/src/components/toggle-group.tsx +55 -0
- package/src/components/tooltip-button.tsx +91 -0
- package/src/components/tooltip.tsx +71 -0
- package/src/components/tree-view.tsx +499 -0
- package/src/components/unglow-loader.tsx +33 -0
- package/src/components/waveform.tsx +1632 -0
- package/src/components/zoomable-image.tsx +76 -0
- package/src/docs/api-docs.tsx +763 -0
- package/src/docs/callout.tsx +160 -0
- package/src/docs/code-group.tsx +215 -0
- package/src/docs/copy-menu.tsx +143 -0
- package/src/docs/doc-tabs.tsx +84 -0
- package/src/docs/index.ts +16 -0
- package/src/docs/steps.tsx +105 -0
- package/src/docs/table-of-contents.tsx +174 -0
- package/src/examples/guide-markdown.ts +129 -0
- package/src/examples/index.ts +1153 -0
- package/src/global.d.ts +1 -0
- package/src/hooks/use-copy-to-clipboard.ts +36 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/lib/display-name.ts +15 -0
- package/src/lib/example-input.ts +130 -0
- package/src/lib/plans.ts +44 -0
- package/src/lib/safe-content.ts +137 -0
- package/src/lib/schema.ts +320 -0
- package/src/lib/storage.ts +108 -0
- package/src/markdown/docs-markdown-renderer.tsx +518 -0
- package/src/markdown/helpers.ts +95 -0
- package/src/markdown/index.ts +24 -0
- package/src/markdown/markdown-renderer.tsx +232 -0
- package/src/markdown/youtube-embed.tsx +43 -0
- package/src/og/image.ts +312 -0
- package/src/og/index.tsx +430 -0
- package/src/og/query.ts +26 -0
- package/src/shadcn/accordion.tsx +71 -0
- package/src/shadcn/alert-dialog.tsx +187 -0
- package/src/shadcn/alert.tsx +75 -0
- package/src/shadcn/aspect-ratio.tsx +22 -0
- package/src/shadcn/attachment.tsx +207 -0
- package/src/shadcn/avatar.tsx +106 -0
- package/src/shadcn/badge.tsx +51 -0
- package/src/shadcn/breadcrumb.tsx +124 -0
- package/src/shadcn/bubble.tsx +127 -0
- package/src/shadcn/button-group.tsx +87 -0
- package/src/shadcn/button.tsx +57 -0
- package/src/shadcn/calendar.tsx +219 -0
- package/src/shadcn/card.tsx +102 -0
- package/src/shadcn/carousel.tsx +242 -0
- package/src/shadcn/chart.tsx +372 -0
- package/src/shadcn/checkbox.tsx +28 -0
- package/src/shadcn/collapsible.tsx +19 -0
- package/src/shadcn/combobox.tsx +297 -0
- package/src/shadcn/command.tsx +194 -0
- package/src/shadcn/context-menu.tsx +270 -0
- package/src/shadcn/dialog.tsx +158 -0
- package/src/shadcn/direction.tsx +4 -0
- package/src/shadcn/drawer.tsx +227 -0
- package/src/shadcn/dropdown-menu.tsx +265 -0
- package/src/shadcn/empty.tsx +103 -0
- package/src/shadcn/field.tsx +238 -0
- package/src/shadcn/form.tsx +173 -0
- package/src/shadcn/hover-card.tsx +50 -0
- package/src/shadcn/index.ts +53 -0
- package/src/shadcn/input-group.tsx +156 -0
- package/src/shadcn/input-otp.tsx +84 -0
- package/src/shadcn/input.tsx +19 -0
- package/src/shadcn/item.tsx +201 -0
- package/src/shadcn/kbd.tsx +26 -0
- package/src/shadcn/label.tsx +19 -0
- package/src/shadcn/marker.tsx +70 -0
- package/src/shadcn/menubar.tsx +278 -0
- package/src/shadcn/message-scroller.tsx +131 -0
- package/src/shadcn/message.tsx +91 -0
- package/src/shadcn/native-select.tsx +60 -0
- package/src/shadcn/navigation-menu.tsx +167 -0
- package/src/shadcn/pagination.tsx +130 -0
- package/src/shadcn/popover.tsx +87 -0
- package/src/shadcn/progress.tsx +82 -0
- package/src/shadcn/radio-group.tsx +35 -0
- package/src/shadcn/resizable.tsx +49 -0
- package/src/shadcn/scroll-area.tsx +52 -0
- package/src/shadcn/select.tsx +200 -0
- package/src/shadcn/separator.tsx +22 -0
- package/src/shadcn/sheet.tsx +136 -0
- package/src/shadcn/sidebar.tsx +721 -0
- package/src/shadcn/skeleton.tsx +13 -0
- package/src/shadcn/slider.tsx +51 -0
- package/src/shadcn/sonner.tsx +49 -0
- package/src/shadcn/spinner.tsx +10 -0
- package/src/shadcn/switch.tsx +29 -0
- package/src/shadcn/table.tsx +115 -0
- package/src/shadcn/tabs.tsx +79 -0
- package/src/shadcn/textarea.tsx +17 -0
- package/src/shadcn/toggle-group.tsx +89 -0
- package/src/shadcn/toggle.tsx +44 -0
- package/src/shadcn/tooltip.tsx +63 -0
- package/src/theme/config.ts +48 -0
- package/src/theme/css/base.css +92 -0
- package/src/theme/css/bitboy.css +75 -0
- package/src/theme/css/dark.css +47 -0
- package/src/theme/css/designer.css +122 -0
- package/src/theme/css/fonts.css +33 -0
- package/src/theme/css/globals.css +14 -0
- package/src/theme/css/hackintosh.css +57 -0
- package/src/theme/css/llama.css +103 -0
- package/src/theme/css/safelist.css +39 -0
- package/src/theme/css/shadcn.css +77 -0
- package/src/theme/css/theme.css +216 -0
- package/src/theme/embed-theme.ts +146 -0
- package/src/theme/fonts/FiraCode-VF.woff2 +0 -0
- package/src/theme/fonts/Hack-Regular.woff2 +0 -0
- package/src/theme/fonts/LICENSE-FiraCode.txt +93 -0
- package/src/theme/fonts/LICENSE-Hack.txt +45 -0
- package/src/theme/fonts/LICENSE-MonaSans.txt +93 -0
- package/src/theme/fonts/Mona-Sans.woff2 +0 -0
- package/src/theme/index.ts +15 -0
- package/src/utils/cn.ts +3 -0
- package/src/utils/index.ts +2 -0
package/src/global.d.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/plans.ts
ADDED
|
@@ -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
|
+
}
|