@kitnai/chat 0.7.0 → 0.8.0
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/README.md +9 -9
- package/dist/custom-elements.json +1626 -883
- package/dist/kitn-chat.es.js +36 -36
- package/dist/llms/llms-full.txt +303 -142
- package/dist/llms/llms.txt +18 -18
- package/dist/schemas/card-envelope.schema.json +14 -0
- package/dist/schemas/card-event.schema.json +12 -0
- package/dist/schemas/confirm.schema.json +65 -0
- package/dist/schemas/embed.schema.json +65 -0
- package/dist/schemas/form.result.schema.json +7 -0
- package/dist/schemas/form.schema.json +33 -0
- package/dist/schemas/link.schema.json +56 -0
- package/dist/schemas/task-list.result.schema.json +16 -0
- package/dist/schemas/task-list.schema.json +78 -0
- package/dist/theme.tokens.css +65 -65
- package/dist/tsx-B8rCNbgL.js +1 -0
- package/dist/typescript-RycA9KXf.js +1 -0
- package/frameworks/react/index.tsx +356 -189
- package/frameworks/react/runtime.tsx +2 -2
- package/llms-full.txt +303 -142
- package/llms.txt +18 -18
- package/package.json +5 -2
- package/src/components/artifact.stories.tsx +138 -0
- package/src/components/artifact.tsx +581 -0
- package/src/components/attachments.stories.tsx +7 -8
- package/src/components/attachments.tsx +2 -2
- package/src/components/card.tsx +110 -0
- package/src/components/chain-of-thought.stories.tsx +7 -8
- package/src/components/chat-container.stories.tsx +7 -8
- package/src/components/chat-container.tsx +4 -0
- package/src/components/checkpoint.stories.tsx +7 -8
- package/src/components/code-block.stories.tsx +8 -9
- package/src/components/component-meta.json +3411 -0
- package/src/components/confirm-card.stories.tsx +74 -0
- package/src/components/confirm-card.tsx +299 -0
- package/src/components/context.stories.tsx +7 -8
- package/src/components/conversation-item.stories.tsx +7 -8
- package/src/components/conversation-item.tsx +2 -2
- package/src/components/conversation-list.stories.tsx +7 -8
- package/src/components/conversation-list.tsx +1 -1
- package/src/components/embed.tsx +196 -0
- package/src/components/empty.stories.tsx +8 -9
- package/src/components/feedback-bar.stories.tsx +7 -8
- package/src/components/file-tree.stories.tsx +73 -0
- package/src/components/file-tree.tsx +383 -0
- package/src/components/file-upload.stories.tsx +7 -8
- package/src/components/form-widgets.tsx +461 -0
- package/src/components/form.tsx +796 -0
- package/src/components/image.stories.tsx +7 -8
- package/src/components/link-card.tsx +194 -0
- package/src/components/loader.stories.tsx +7 -8
- package/src/components/markdown.stories.tsx +7 -8
- package/src/components/message-narrow.stories.tsx +12 -13
- package/src/components/message-skills.stories.tsx +16 -17
- package/src/components/message.stories.tsx +17 -18
- package/src/components/model-switcher.stories.tsx +7 -8
- package/src/components/prompt-input.stories.tsx +8 -9
- package/src/components/prompt-suggestion.stories.tsx +7 -8
- package/src/components/prompt-suggestion.tsx +3 -3
- package/src/components/reasoning.stories.tsx +7 -8
- package/src/components/scroll-button.stories.tsx +7 -8
- package/src/components/slash-command.stories.tsx +8 -9
- package/src/components/slash-command.tsx +2 -2
- package/src/components/source.stories.tsx +7 -8
- package/src/components/source.tsx +1 -1
- package/src/components/task-list-card.stories.tsx +78 -0
- package/src/components/task-list-card.tsx +388 -0
- package/src/components/text-shimmer.stories.tsx +7 -8
- package/src/components/thinking-bar.stories.tsx +7 -8
- package/src/components/tool.stories.tsx +7 -8
- package/src/components/tool.tsx +2 -2
- package/src/components/voice-input.stories.tsx +7 -8
- package/src/elements/artifact.stories.tsx +291 -0
- package/src/elements/artifact.tsx +72 -0
- package/src/elements/{kitn-attachments.stories.tsx → attachments.stories.tsx} +11 -20
- package/src/elements/attachments.tsx +4 -4
- package/src/elements/card.stories.tsx +118 -0
- package/src/elements/card.tsx +40 -0
- package/src/elements/catalog.stories.tsx +491 -0
- package/src/elements/{kitn-chain-of-thought.stories.tsx → chain-of-thought.stories.tsx} +13 -22
- package/src/elements/chain-of-thought.tsx +3 -3
- package/src/elements/{kitn-chat-scope-picker.stories.tsx → chat-scope-picker.stories.tsx} +10 -19
- package/src/elements/chat-scope-picker.tsx +4 -4
- package/src/elements/{kitn-chat-workspace.stories.tsx → chat-workspace.stories.tsx} +15 -23
- package/src/elements/chat-workspace.tsx +2 -2
- package/src/elements/{kitn-chat.stories.tsx → chat.stories.tsx} +12 -20
- package/src/elements/chat.tsx +2 -2
- package/src/elements/{kitn-checkpoint.stories.tsx → checkpoint.stories.tsx} +11 -20
- package/src/elements/checkpoint.tsx +4 -4
- package/src/elements/{kitn-code-block.stories.tsx → code-block.stories.tsx} +10 -19
- package/src/elements/code-block.tsx +3 -3
- package/src/elements/compiled.css +1 -1
- package/src/elements/composed-shell.stories.tsx +316 -0
- package/src/elements/confirm-card.stories.tsx +186 -0
- package/src/elements/confirm-card.tsx +45 -0
- package/src/elements/{kitn-context-meter.stories.tsx → context-meter.stories.tsx} +10 -19
- package/src/elements/context-meter.tsx +3 -3
- package/src/elements/{kitn-conversation-list.stories.tsx → conversation-list.stories.tsx} +12 -20
- package/src/elements/conversation-list.tsx +2 -2
- package/src/elements/css.ts +1 -1
- package/src/elements/define.tsx +10 -10
- package/src/elements/element-meta.json +1379 -733
- package/src/elements/element-types.d.ts +251 -125
- package/src/elements/embed.stories.tsx +197 -0
- package/src/elements/embed.tsx +35 -0
- package/src/elements/{kitn-empty.stories.tsx → empty.stories.tsx} +12 -21
- package/src/elements/empty.tsx +3 -3
- package/src/elements/{kitn-feedback-bar.stories.tsx → feedback-bar.stories.tsx} +11 -20
- package/src/elements/feedback-bar.tsx +4 -4
- package/src/elements/file-tree.stories.tsx +133 -0
- package/src/elements/file-tree.tsx +52 -0
- package/src/elements/{kitn-file-upload.stories.tsx → file-upload.stories.tsx} +12 -21
- package/src/elements/file-upload.tsx +4 -4
- package/src/elements/form.stories.tsx +204 -0
- package/src/elements/form.tsx +37 -0
- package/src/elements/{kitn-image.stories.tsx → image.stories.tsx} +10 -19
- package/src/elements/image.tsx +3 -3
- package/src/elements/link-card.stories.tsx +193 -0
- package/src/elements/link-card.tsx +34 -0
- package/src/elements/{kitn-loader.stories.tsx → loader.stories.tsx} +11 -20
- package/src/elements/loader.tsx +3 -3
- package/src/elements/{kitn-markdown.stories.tsx → markdown.stories.tsx} +10 -19
- package/src/elements/markdown.tsx +3 -3
- package/src/elements/{kitn-message-skills.stories.tsx → message-skills.stories.tsx} +10 -19
- package/src/elements/message-skills.tsx +3 -3
- package/src/elements/{kitn-message.stories.tsx → message.stories.tsx} +12 -21
- package/src/elements/message.tsx +5 -5
- package/src/elements/{kitn-model-switcher.stories.tsx → model-switcher.stories.tsx} +10 -19
- package/src/elements/model-switcher.tsx +5 -5
- package/src/elements/{kitn-prompt-input.stories.tsx → prompt-input.stories.tsx} +14 -22
- package/src/elements/prompt-input.tsx +3 -3
- package/src/elements/{kitn-prompt-suggestions.stories.tsx → prompt-suggestions.stories.tsx} +13 -22
- package/src/elements/prompt-suggestions.tsx +4 -4
- package/src/elements/{kitn-reasoning.stories.tsx → reasoning.stories.tsx} +10 -19
- package/src/elements/reasoning.tsx +4 -4
- package/src/elements/register.ts +11 -1
- package/src/elements/resizable.stories.tsx +200 -0
- package/src/elements/resizable.tsx +264 -0
- package/src/elements/{kitn-response-stream.stories.tsx → response-stream.stories.tsx} +10 -19
- package/src/elements/response-stream.tsx +4 -4
- package/src/elements/{kitn-source-list.stories.tsx → source-list.stories.tsx} +11 -20
- package/src/elements/{kitn-source.stories.tsx → source.stories.tsx} +12 -21
- package/src/elements/source.tsx +5 -5
- package/src/elements/styles.css +140 -1
- package/src/elements/task-list-card.stories.tsx +194 -0
- package/src/elements/task-list-card.tsx +40 -0
- package/src/elements/{kitn-text-shimmer.stories.tsx → text-shimmer.stories.tsx} +10 -19
- package/src/elements/text-shimmer.tsx +3 -3
- package/src/elements/{kitn-thinking-bar.stories.tsx → thinking-bar.stories.tsx} +11 -20
- package/src/elements/thinking-bar.tsx +5 -5
- package/src/elements/{kitn-tool.stories.tsx → tool.stories.tsx} +10 -19
- package/src/elements/tool.tsx +3 -3
- package/src/elements/{kitn-voice-input.stories.tsx → voice-input.stories.tsx} +10 -19
- package/src/elements/voice-input.tsx +4 -4
- package/src/index.ts +94 -2
- package/src/primitives/card-contract.ts +60 -0
- package/src/primitives/card-host.tsx +35 -0
- package/src/primitives/card-routing.ts +79 -0
- package/src/primitives/card-schemas/card-envelope.schema.json +14 -0
- package/src/primitives/card-schemas/card-event.schema.json +12 -0
- package/src/primitives/card-schemas/confirm.schema.json +65 -0
- package/src/primitives/card-schemas/embed.schema.json +65 -0
- package/src/primitives/card-schemas/form.result.schema.json +7 -0
- package/src/primitives/card-schemas/form.schema.json +33 -0
- package/src/primitives/card-schemas/link.schema.json +56 -0
- package/src/primitives/card-schemas/task-list.result.schema.json +16 -0
- package/src/primitives/card-schemas/task-list.schema.json +78 -0
- package/src/primitives/card-validate.ts +95 -0
- package/src/primitives/embed-providers.ts +254 -0
- package/src/primitives/highlighter.ts +4 -0
- package/src/primitives/link-preview.ts +87 -0
- package/src/primitives/pdf-preview.ts +121 -0
- package/src/stories/chat-panel-layout.stories.tsx +2 -1
- package/src/stories/chat-scene.tsx +22 -21
- package/src/stories/checkpoint-restore.stories.tsx +10 -10
- package/src/stories/conversation-with-reasoning.stories.tsx +4 -4
- package/src/stories/conversation-with-sources.stories.tsx +7 -7
- package/src/stories/docs/Accessibility.mdx +2 -2
- package/src/stories/docs/ForAIAgents.mdx +3 -3
- package/src/stories/docs/GettingStarted.mdx +2 -2
- package/src/stories/docs/Installation.mdx +2 -2
- package/src/stories/docs/Integrations.mdx +29 -29
- package/src/stories/docs/Introduction.mdx +3 -3
- package/src/stories/docs/Theming.mdx +2 -2
- package/src/stories/docs/element-controls.ts +32 -0
- package/src/stories/docs/theme-editor/theme-editor.tsx +1 -0
- package/src/stories/examples/ChoosingComponents.mdx +94 -0
- package/src/stories/examples/sample-data.ts +79 -0
- package/src/stories/message-actions.stories.tsx +13 -13
- package/src/stories/pattern-centered-conversation.stories.tsx +3 -3
- package/src/stories/pattern-docked-widget.stories.tsx +1 -1
- package/src/stories/pattern-empty-state.stories.tsx +3 -3
- package/src/stories/prompt-input-variants.stories.tsx +13 -13
- package/src/stories/streaming-response.stories.tsx +3 -3
- package/src/stories/typography.stories.tsx +4 -4
- package/src/ui/avatar.stories.tsx +7 -8
- package/src/ui/badge.stories.tsx +7 -8
- package/src/ui/button.stories.tsx +8 -9
- package/src/ui/button.tsx +1 -0
- package/src/ui/collapsible.stories.tsx +6 -7
- package/src/ui/dropdown.stories.tsx +6 -7
- package/src/ui/hover-card.stories.tsx +6 -7
- package/src/ui/resizable.stories.tsx +74 -9
- package/src/ui/resizable.tsx +351 -71
- package/src/ui/scroll-area.stories.tsx +6 -7
- package/src/ui/scroll-area.tsx +3 -1
- package/src/ui/separator.stories.tsx +7 -8
- package/src/ui/skeleton.stories.tsx +7 -8
- package/src/ui/textarea.stories.tsx +6 -7
- package/src/ui/tooltip.stories.tsx +8 -9
- package/theme.css +65 -65
- package/src/stories/docs/element-spec.tsx +0 -86
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://kitn.ai/schemas/card/form.result.schema.json",
|
|
4
|
+
"title": "kc-form submit-data payload",
|
|
5
|
+
"description": "The CardEvent {kind:'submit-data'} `data` for a form card: an object that validates against the form-definition schema that was sent down in CardEnvelope.data. Standard JSON Schema validation (type/enum/required/min-max/length/pattern) holds. Optional empty strings are omitted.",
|
|
6
|
+
"type": "object"
|
|
7
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://kitn.ai/schemas/card/form.schema.json",
|
|
4
|
+
"title": "kc-form data (a form definition)",
|
|
5
|
+
"description": "The CardEnvelope.data for a card of type 'form': a JSON Schema (type:'object') describing the fields kc-form renders, plus optional x-kc-* UI hints. The same schema validates the submission.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["type", "properties"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"type": { "const": "object" },
|
|
10
|
+
"title": { "type": "string" },
|
|
11
|
+
"description": { "type": "string" },
|
|
12
|
+
"required": { "type": "array", "items": { "type": "string" } },
|
|
13
|
+
"properties": {
|
|
14
|
+
"type": "object"
|
|
15
|
+
},
|
|
16
|
+
"x-kc-order": { "type": "array", "items": { "type": "string" } },
|
|
17
|
+
"x-kc-inlineMax": { "type": "integer", "minimum": 1 },
|
|
18
|
+
"x-kc-submitLabel": { "type": "string" },
|
|
19
|
+
"x-kc-dismissible": { "type": "boolean" },
|
|
20
|
+
"x-kc-actions": {
|
|
21
|
+
"type": "array",
|
|
22
|
+
"items": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"required": ["id", "label"],
|
|
25
|
+
"properties": {
|
|
26
|
+
"id": { "type": "string" },
|
|
27
|
+
"label": { "type": "string" },
|
|
28
|
+
"variant": { "enum": ["default", "ghost", "outline"] }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://kitn.ai/schemas/card/link.schema.json",
|
|
4
|
+
"title": "LinkCardData",
|
|
5
|
+
"description": "Rich link / Open-Graph preview payload. The card renders from this; it never fetches.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"x-kc-card-type": "link",
|
|
8
|
+
"x-kc-contract-version": "1",
|
|
9
|
+
"required": ["url"],
|
|
10
|
+
"additionalProperties": false,
|
|
11
|
+
"properties": {
|
|
12
|
+
"url": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"format": "uri",
|
|
15
|
+
"description": "Canonical destination. Opened via the contract `open` verb (target 'tab').",
|
|
16
|
+
"x-kc-format": "url"
|
|
17
|
+
},
|
|
18
|
+
"title": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "OG title (og:title). Falls back to the domain when absent.",
|
|
21
|
+
"maxLength": 300
|
|
22
|
+
},
|
|
23
|
+
"description": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"description": "OG description (og:description). Clamped to 3 lines in the UI.",
|
|
26
|
+
"maxLength": 1000
|
|
27
|
+
},
|
|
28
|
+
"image": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"format": "uri",
|
|
31
|
+
"description": "Preview image (og:image). Optional; the card degrades gracefully when missing or it fails to load.",
|
|
32
|
+
"x-kc-format": "url"
|
|
33
|
+
},
|
|
34
|
+
"imageAlt": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"description": "Alt text for the preview image. Defaults to the title (or empty = decorative) when omitted.",
|
|
37
|
+
"maxLength": 300
|
|
38
|
+
},
|
|
39
|
+
"favicon": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"format": "uri",
|
|
42
|
+
"description": "Site favicon shown next to the domain.",
|
|
43
|
+
"x-kc-format": "url"
|
|
44
|
+
},
|
|
45
|
+
"domain": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "Display domain (e.g. 'example.com'). Derived from `url` when omitted.",
|
|
48
|
+
"maxLength": 253
|
|
49
|
+
},
|
|
50
|
+
"siteName": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"description": "OG site name (og:site_name). Shown in place of the domain when present.",
|
|
53
|
+
"maxLength": 200
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://kitn.ai/schemas/card/task-list.result.schema.json",
|
|
4
|
+
"title": "TaskListCardResult",
|
|
5
|
+
"description": "Payload of CardEvent { kind: 'submit-data' } from a task-list card.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["selected"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"selected": {
|
|
10
|
+
"type": "array",
|
|
11
|
+
"description": "Ids of the checked tasks, in the order they appear in `tasks`. Subset of the input task ids.",
|
|
12
|
+
"items": { "type": "string", "minLength": 1 },
|
|
13
|
+
"uniqueItems": true
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://kitn.ai/schemas/card/task-list.schema.json",
|
|
4
|
+
"title": "TaskListCardData",
|
|
5
|
+
"description": "Data payload for a `task-list` card (CardEnvelope.data when type='task-list').",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["tasks"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"mode": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"enum": ["select"],
|
|
12
|
+
"default": "select",
|
|
13
|
+
"description": "v1 supports only 'select' (toggle + confirm). 'progress' (live AG-UI status) is a future enum value; restricting it now keeps the wire forward-compatible.",
|
|
14
|
+
"x-kc-mode": true
|
|
15
|
+
},
|
|
16
|
+
"heading": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "Optional in-body heading; distinct from CardEnvelope.title."
|
|
19
|
+
},
|
|
20
|
+
"tasks": {
|
|
21
|
+
"type": "array",
|
|
22
|
+
"minItems": 1,
|
|
23
|
+
"description": "The selectable rows, rendered in order.",
|
|
24
|
+
"items": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"required": ["id", "label"],
|
|
27
|
+
"properties": {
|
|
28
|
+
"id": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"minLength": 1,
|
|
31
|
+
"description": "Stable id; the selected ids are returned in the result. Unique within `tasks`.",
|
|
32
|
+
"x-kc-unique": true
|
|
33
|
+
},
|
|
34
|
+
"label": { "type": "string", "minLength": 1, "description": "Row label." },
|
|
35
|
+
"description": { "type": "string", "description": "Optional secondary line under the label." },
|
|
36
|
+
"checked": {
|
|
37
|
+
"type": "boolean",
|
|
38
|
+
"default": false,
|
|
39
|
+
"description": "Initial checked state of the row."
|
|
40
|
+
},
|
|
41
|
+
"disabled": {
|
|
42
|
+
"type": "boolean",
|
|
43
|
+
"default": false,
|
|
44
|
+
"description": "Row is shown but not toggleable (and excluded from select-all)."
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"selectAll": {
|
|
50
|
+
"type": "boolean",
|
|
51
|
+
"default": false,
|
|
52
|
+
"description": "Render a master select-all checkbox above the list.",
|
|
53
|
+
"x-kc-control": "select-all"
|
|
54
|
+
},
|
|
55
|
+
"confirmLabel": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"default": "Confirm",
|
|
58
|
+
"description": "Label for the confirm button."
|
|
59
|
+
},
|
|
60
|
+
"allowEmpty": {
|
|
61
|
+
"type": "boolean",
|
|
62
|
+
"default": false,
|
|
63
|
+
"description": "If true, confirm is enabled with zero selected (emits { selected: [] }). If false, confirm is disabled until >=1 selected."
|
|
64
|
+
},
|
|
65
|
+
"min": {
|
|
66
|
+
"type": "integer",
|
|
67
|
+
"minimum": 0,
|
|
68
|
+
"description": "Optional minimum number that must be selected to confirm.",
|
|
69
|
+
"x-kc-select-min": true
|
|
70
|
+
},
|
|
71
|
+
"max": {
|
|
72
|
+
"type": "integer",
|
|
73
|
+
"minimum": 1,
|
|
74
|
+
"description": "Optional maximum selectable; further toggles are blocked once reached.",
|
|
75
|
+
"x-kc-select-max": true
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// src/primitives/card-validate.ts
|
|
2
|
+
// The single shared lean JSON-Schema validator the contract mandates. Covers the
|
|
3
|
+
// subset cards use; `x-*` keywords (incl. x-kc-*) are ignored. No ajv. Used at
|
|
4
|
+
// every boundary (incoming card data, outgoing payloads) by cards + both transports.
|
|
5
|
+
|
|
6
|
+
export interface JsonSchema {
|
|
7
|
+
type?: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' | 'null';
|
|
8
|
+
const?: unknown;
|
|
9
|
+
enum?: unknown[];
|
|
10
|
+
required?: string[];
|
|
11
|
+
properties?: Record<string, JsonSchema>;
|
|
12
|
+
items?: JsonSchema;
|
|
13
|
+
minimum?: number; maximum?: number;
|
|
14
|
+
exclusiveMinimum?: number; exclusiveMaximum?: number;
|
|
15
|
+
minLength?: number; maxLength?: number;
|
|
16
|
+
pattern?: string;
|
|
17
|
+
minItems?: number; maxItems?: number;
|
|
18
|
+
uniqueItems?: boolean;
|
|
19
|
+
// x-* keywords (e.g. x-kc-widget) are allowed and ignored.
|
|
20
|
+
[key: `x-${string}`]: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ValidationResult {
|
|
24
|
+
valid: boolean;
|
|
25
|
+
errors: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function typeOf(v: unknown): string {
|
|
29
|
+
if (v === null) return 'null';
|
|
30
|
+
if (Array.isArray(v)) return 'array';
|
|
31
|
+
return typeof v;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function matchesType(v: unknown, t: NonNullable<JsonSchema['type']>): boolean {
|
|
35
|
+
switch (t) {
|
|
36
|
+
case 'integer': return typeof v === 'number' && Number.isInteger(v);
|
|
37
|
+
case 'number': return typeof v === 'number' && Number.isFinite(v);
|
|
38
|
+
case 'array': return Array.isArray(v);
|
|
39
|
+
case 'null': return v === null;
|
|
40
|
+
case 'object': return typeOf(v) === 'object';
|
|
41
|
+
default: return typeof v === t;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function walk(schema: JsonSchema, value: unknown, path: string, errors: string[]): void {
|
|
46
|
+
const at = path || '(root)';
|
|
47
|
+
if (schema.type && !matchesType(value, schema.type)) {
|
|
48
|
+
errors.push(`${at}: expected ${schema.type}, got ${typeOf(value)}`);
|
|
49
|
+
return; // type wrong → downstream checks are meaningless
|
|
50
|
+
}
|
|
51
|
+
if ('const' in schema && JSON.stringify(value) !== JSON.stringify(schema.const)) {
|
|
52
|
+
errors.push(`${at}: must equal const ${JSON.stringify(schema.const)}`);
|
|
53
|
+
}
|
|
54
|
+
if (schema.enum && !schema.enum.some((e) => JSON.stringify(e) === JSON.stringify(value))) {
|
|
55
|
+
errors.push(`${at}: must be one of ${JSON.stringify(schema.enum)}`);
|
|
56
|
+
}
|
|
57
|
+
if (typeof value === 'number') {
|
|
58
|
+
if (schema.minimum !== undefined && value < schema.minimum) errors.push(`${at}: < minimum ${schema.minimum}`);
|
|
59
|
+
if (schema.maximum !== undefined && value > schema.maximum) errors.push(`${at}: > maximum ${schema.maximum}`);
|
|
60
|
+
if (schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) errors.push(`${at}: <= exclusiveMinimum`);
|
|
61
|
+
if (schema.exclusiveMaximum !== undefined && value >= schema.exclusiveMaximum) errors.push(`${at}: >= exclusiveMaximum`);
|
|
62
|
+
}
|
|
63
|
+
if (typeof value === 'string') {
|
|
64
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) errors.push(`${at}: shorter than minLength ${schema.minLength}`);
|
|
65
|
+
if (schema.maxLength !== undefined && value.length > schema.maxLength) errors.push(`${at}: longer than maxLength ${schema.maxLength}`);
|
|
66
|
+
if (schema.pattern !== undefined && !new RegExp(schema.pattern).test(value)) errors.push(`${at}: does not match pattern`);
|
|
67
|
+
}
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) errors.push(`${at}: fewer than minItems ${schema.minItems}`);
|
|
70
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) errors.push(`${at}: more than maxItems ${schema.maxItems}`);
|
|
71
|
+
if (schema.uniqueItems) {
|
|
72
|
+
const seen = new Set(value.map((v) => JSON.stringify(v)));
|
|
73
|
+
if (seen.size !== value.length) errors.push(`${at}: items not unique`);
|
|
74
|
+
}
|
|
75
|
+
if (schema.items) value.forEach((v, i) => walk(schema.items!, v, `${at}[${i}]`, errors));
|
|
76
|
+
}
|
|
77
|
+
if (typeOf(value) === 'object') {
|
|
78
|
+
const obj = value as Record<string, unknown>;
|
|
79
|
+
for (const key of schema.required ?? []) {
|
|
80
|
+
if (!(key in obj) || obj[key] === undefined) errors.push(`${at}.${key}: required`);
|
|
81
|
+
}
|
|
82
|
+
if (schema.properties) {
|
|
83
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
84
|
+
if (key in obj && obj[key] !== undefined) walk(sub, obj[key], `${at}.${key}`, errors);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Validate `value` against the lean JSON-Schema subset. */
|
|
91
|
+
export function validateAgainstSchema(schema: JsonSchema, value: unknown): ValidationResult {
|
|
92
|
+
const errors: string[] = [];
|
|
93
|
+
walk(schema, value, '', errors);
|
|
94
|
+
return { valid: errors.length === 0, errors };
|
|
95
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// src/primitives/embed-providers.ts
|
|
2
|
+
// Pure provider resolution for <kc-embed>: map an EmbedCardData → an embeddable
|
|
3
|
+
// player URL + poster + iframe sandbox/allow. Covers youtube (privacy-enhanced
|
|
4
|
+
// youtube-nocookie), vimeo (dnt=1), and generic (https-only, ORIGIN-ALLOWLISTED).
|
|
5
|
+
// No network, no DOM. See docs/superpowers/specs/2026-06-13-kc-link-embed-cards-design.md.
|
|
6
|
+
import type { CardEnvelope } from './card-contract';
|
|
7
|
+
|
|
8
|
+
/** Media provider for an embed card. */
|
|
9
|
+
export type EmbedProvider = 'youtube' | 'vimeo' | 'generic';
|
|
10
|
+
|
|
11
|
+
/** Lazy media-embed payload (YouTube / Vimeo / generic player URL). */
|
|
12
|
+
export interface EmbedCardData {
|
|
13
|
+
/** Media provider. 'generic' frames `url` directly. */
|
|
14
|
+
provider: EmbedProvider;
|
|
15
|
+
/** Provider video id (youtube/vimeo) when not parsing from `url`. */
|
|
16
|
+
id?: string;
|
|
17
|
+
/** Full media/watch/embed URL. */
|
|
18
|
+
url?: string;
|
|
19
|
+
/** Accessible iframe title + poster label. */
|
|
20
|
+
title?: string;
|
|
21
|
+
/** Thumbnail before play; derived for youtube/vimeo when omitted. */
|
|
22
|
+
poster?: string;
|
|
23
|
+
/** Start offset, seconds. */
|
|
24
|
+
start?: number;
|
|
25
|
+
/** Player aspect ratio. Default '16:9'. */
|
|
26
|
+
aspectRatio?: '16:9' | '4:3' | '1:1' | '9:16';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The full envelope an agent/server emits for an embed card. */
|
|
30
|
+
export type EmbedCardEnvelope = CardEnvelope<'embed', EmbedCardData>;
|
|
31
|
+
|
|
32
|
+
/** The `type` discriminator for embed cards. */
|
|
33
|
+
export const EMBED_CARD_TYPE = 'embed' as const;
|
|
34
|
+
|
|
35
|
+
export interface ResolvedEmbed {
|
|
36
|
+
/** The iframe src loaded on play (already including autoplay/start params). */
|
|
37
|
+
embedUrl: string;
|
|
38
|
+
/** Poster/thumbnail to show before play (derived when not supplied). */
|
|
39
|
+
posterUrl?: string;
|
|
40
|
+
/** sandbox attribute for the player iframe. */
|
|
41
|
+
sandbox: string;
|
|
42
|
+
/** allow attribute (fullscreen, encrypted-media, picture-in-picture, …). */
|
|
43
|
+
allow: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A provider player NEEDS allow-scripts + allow-same-origin (its OWN origin) to run.
|
|
47
|
+
// That is safe here because the framed origin is a KNOWN, trusted video provider on a
|
|
48
|
+
// DIFFERENT origin than the host — same-origin policy still isolates the host page from
|
|
49
|
+
// the provider. allow-popups(-to-escape-sandbox) lets "watch on YouTube" work. The
|
|
50
|
+
// `allow` attribute (not sandbox) governs autoplay/fullscreen/encrypted-media.
|
|
51
|
+
// Contrast <kc-artifact> (allow-scripts allow-forms, NO allow-same-origin) which frames
|
|
52
|
+
// arbitrary consumer HTML and so trusts nothing.
|
|
53
|
+
const PLAYER_SANDBOX =
|
|
54
|
+
'allow-scripts allow-same-origin allow-presentation allow-popups allow-popups-to-escape-sandbox';
|
|
55
|
+
const PLAYER_ALLOW =
|
|
56
|
+
'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen';
|
|
57
|
+
|
|
58
|
+
// --- generic origin allowlist (security decision) -------------------------
|
|
59
|
+
//
|
|
60
|
+
// A `generic` embed frames an ARBITRARY https URL. An agent-supplied generic URL is a
|
|
61
|
+
// supply-chain risk, so it is REJECTED unless the app has explicitly allowlisted its
|
|
62
|
+
// origin. The allowlist defaults to EMPTY: out of the box, `generic` embeds are blocked.
|
|
63
|
+
const allowedGenericOrigins = new Set<string>();
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* App opt-in: allow `generic` embeds whose origin is in this list. Origins are
|
|
67
|
+
* normalized via the URL parser (scheme + host + port). Only https origins are
|
|
68
|
+
* accepted (a non-https origin is ignored). Call once at app startup.
|
|
69
|
+
*/
|
|
70
|
+
export function configureEmbedAllowlist(origins: string[]): void {
|
|
71
|
+
for (const o of origins) {
|
|
72
|
+
const origin = normalizeOrigin(o);
|
|
73
|
+
if (origin) allowedGenericOrigins.add(origin);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True when `url`'s origin has been allowlisted for `generic` embeds. */
|
|
78
|
+
export function isGenericOriginAllowed(url: string): boolean {
|
|
79
|
+
const origin = originOf(url);
|
|
80
|
+
return origin !== undefined && allowedGenericOrigins.has(origin);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Test-only: clear the generic allowlist so tests stay isolated. */
|
|
84
|
+
export function __resetEmbedAllowlistForTests(): void {
|
|
85
|
+
allowedGenericOrigins.clear();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Normalize an allowlist entry (a URL or bare origin) to a canonical https origin, or undefined. */
|
|
89
|
+
function normalizeOrigin(input: string): string | undefined {
|
|
90
|
+
const origin = originOf(input) ?? originOf(`https://${input}`);
|
|
91
|
+
if (!origin) return undefined;
|
|
92
|
+
return origin.startsWith('https://') ? origin : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The `scheme://host[:port]` origin of a URL, or undefined when unparseable. */
|
|
96
|
+
function originOf(url: string): string | undefined {
|
|
97
|
+
try {
|
|
98
|
+
return new URL(url).origin;
|
|
99
|
+
} catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- id parsing -----------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/** Extract a YouTube id from a watch / youtu.be / shorts / embed URL. */
|
|
107
|
+
export function parseYouTubeId(url: string): string | undefined {
|
|
108
|
+
let parsed: URL;
|
|
109
|
+
try {
|
|
110
|
+
parsed = new URL(url);
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
const host = parsed.hostname.replace(/^www\./, '');
|
|
115
|
+
if (host === 'youtu.be') {
|
|
116
|
+
return cleanId(parsed.pathname.slice(1));
|
|
117
|
+
}
|
|
118
|
+
if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'youtube-nocookie.com') {
|
|
119
|
+
const v = parsed.searchParams.get('v');
|
|
120
|
+
if (v) return cleanId(v);
|
|
121
|
+
// /shorts/<id>, /embed/<id>, /v/<id>
|
|
122
|
+
const m = parsed.pathname.match(/^\/(?:shorts|embed|v)\/([^/?#]+)/);
|
|
123
|
+
if (m) return cleanId(m[1]);
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Extract a Vimeo id from a vimeo.com/<id> (or player.vimeo.com/video/<id>) URL. */
|
|
129
|
+
export function parseVimeoId(url: string): string | undefined {
|
|
130
|
+
let parsed: URL;
|
|
131
|
+
try {
|
|
132
|
+
parsed = new URL(url);
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
const host = parsed.hostname.replace(/^www\./, '');
|
|
137
|
+
if (host !== 'vimeo.com' && host !== 'player.vimeo.com') return undefined;
|
|
138
|
+
const m = parsed.pathname.match(/(?:^|\/)(\d+)(?:\/|$)/);
|
|
139
|
+
return m ? m[1] : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A provider video id must be the [A-Za-z0-9_-] alphabet (matches the schema pattern). */
|
|
143
|
+
function cleanId(id: string): string | undefined {
|
|
144
|
+
return /^[A-Za-z0-9_-]+$/.test(id) ? id : undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- resolution -----------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve an EmbedCardData to an embeddable player URL + poster + sandbox/allow.
|
|
151
|
+
* Throws (with a human message) on a missing/invalid provider id, a non-https
|
|
152
|
+
* generic URL, or a generic origin not in the app allowlist — the card turns these
|
|
153
|
+
* into an inline error + an `error` event.
|
|
154
|
+
*/
|
|
155
|
+
export function resolveEmbed(data: EmbedCardData): ResolvedEmbed {
|
|
156
|
+
const start = data.start ? `&start=${data.start}` : '';
|
|
157
|
+
switch (data.provider) {
|
|
158
|
+
case 'youtube': {
|
|
159
|
+
const id = data.id ?? (data.url ? parseYouTubeId(data.url) : undefined);
|
|
160
|
+
if (!id) throw new Error('youtube embed: missing or unparseable id/url');
|
|
161
|
+
return {
|
|
162
|
+
embedUrl: `https://www.youtube-nocookie.com/embed/${id}?autoplay=1&rel=0${start}`,
|
|
163
|
+
posterUrl: data.poster ?? `https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
|
|
164
|
+
sandbox: PLAYER_SANDBOX,
|
|
165
|
+
allow: PLAYER_ALLOW,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
case 'vimeo': {
|
|
169
|
+
const id = data.id ?? (data.url ? parseVimeoId(data.url) : undefined);
|
|
170
|
+
if (!id) throw new Error('vimeo embed: missing or unparseable id/url');
|
|
171
|
+
return {
|
|
172
|
+
embedUrl: `https://player.vimeo.com/video/${id}?autoplay=1&dnt=1${
|
|
173
|
+
data.start ? `#t=${data.start}s` : ''
|
|
174
|
+
}`,
|
|
175
|
+
// Vimeo has no static thumbnail URL; rely on a supplied poster (or placeholder).
|
|
176
|
+
posterUrl: data.poster,
|
|
177
|
+
sandbox: PLAYER_SANDBOX,
|
|
178
|
+
allow: PLAYER_ALLOW,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
case 'generic': {
|
|
182
|
+
if (!data.url) throw new Error('generic embed: missing url');
|
|
183
|
+
assertHttpsEmbeddable(data.url);
|
|
184
|
+
if (!isGenericOriginAllowed(data.url)) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`generic embed: origin not allowlisted — call configureEmbedAllowlist([...]) to permit ${
|
|
187
|
+
originOf(data.url) ?? data.url
|
|
188
|
+
}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return { embedUrl: data.url, posterUrl: data.poster, sandbox: PLAYER_SANDBOX, allow: PLAYER_ALLOW };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Reject non-https or javascript:/data: player URLs (defense in depth). */
|
|
197
|
+
function assertHttpsEmbeddable(url: string): void {
|
|
198
|
+
let protocol: string;
|
|
199
|
+
try {
|
|
200
|
+
protocol = new URL(url).protocol;
|
|
201
|
+
} catch {
|
|
202
|
+
throw new Error(`generic embed: invalid url "${url}"`);
|
|
203
|
+
}
|
|
204
|
+
if (protocol !== 'https:') {
|
|
205
|
+
throw new Error(`generic embed: only https player URLs are allowed (got ${protocol})`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The canonical "watch on the provider" URL for the optional fallback affordance
|
|
211
|
+
* (emitted as `open`/target:'tab' when an embed is blocked). Returns `undefined`
|
|
212
|
+
* when there is nothing useful to link to.
|
|
213
|
+
*/
|
|
214
|
+
export function watchUrl(data: EmbedCardData): string | undefined {
|
|
215
|
+
switch (data.provider) {
|
|
216
|
+
case 'youtube': {
|
|
217
|
+
const id = data.id ?? (data.url ? parseYouTubeId(data.url) : undefined);
|
|
218
|
+
return id ? `https://www.youtube.com/watch?v=${id}` : data.url;
|
|
219
|
+
}
|
|
220
|
+
case 'vimeo': {
|
|
221
|
+
const id = data.id ?? (data.url ? parseVimeoId(data.url) : undefined);
|
|
222
|
+
return id ? `https://vimeo.com/${id}` : data.url;
|
|
223
|
+
}
|
|
224
|
+
case 'generic':
|
|
225
|
+
return data.url;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Human-readable provider label for the fallback affordance ("Open on YouTube"). */
|
|
230
|
+
export function providerLabel(provider: EmbedProvider): string {
|
|
231
|
+
switch (provider) {
|
|
232
|
+
case 'youtube':
|
|
233
|
+
return 'YouTube';
|
|
234
|
+
case 'vimeo':
|
|
235
|
+
return 'Vimeo';
|
|
236
|
+
case 'generic':
|
|
237
|
+
return 'site';
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** CSS aspect-ratio value for a card's aspectRatio (default 16:9). */
|
|
242
|
+
export function aspectRatioValue(aspectRatio: EmbedCardData['aspectRatio']): string {
|
|
243
|
+
switch (aspectRatio) {
|
|
244
|
+
case '4:3':
|
|
245
|
+
return '4 / 3';
|
|
246
|
+
case '1:1':
|
|
247
|
+
return '1 / 1';
|
|
248
|
+
case '9:16':
|
|
249
|
+
return '9 / 16';
|
|
250
|
+
case '16:9':
|
|
251
|
+
default:
|
|
252
|
+
return '16 / 9';
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -20,6 +20,8 @@ type Loader = () => Promise<unknown>;
|
|
|
20
20
|
const DEFAULT_LANGUAGES: Record<string, Loader> = {
|
|
21
21
|
bash: () => import('@shikijs/langs/bash'),
|
|
22
22
|
javascript: () => import('@shikijs/langs/javascript'),
|
|
23
|
+
typescript: () => import('@shikijs/langs/typescript'),
|
|
24
|
+
tsx: () => import('@shikijs/langs/tsx'),
|
|
23
25
|
html: () => import('@shikijs/langs/html'),
|
|
24
26
|
css: () => import('@shikijs/langs/css'),
|
|
25
27
|
json: () => import('@shikijs/langs/json'),
|
|
@@ -32,6 +34,8 @@ const DEFAULT_THEMES: Record<string, Loader> = {
|
|
|
32
34
|
|
|
33
35
|
const DEFAULT_ALIASES: Record<string, string> = {
|
|
34
36
|
js: 'javascript',
|
|
37
|
+
ts: 'typescript',
|
|
38
|
+
jsx: 'tsx',
|
|
35
39
|
sh: 'bash',
|
|
36
40
|
shell: 'bash',
|
|
37
41
|
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// src/primitives/link-preview.ts
|
|
2
|
+
// The optional, app-supplied bare-URL → metadata hook for <kc-link-card>, plus the
|
|
3
|
+
// `link` card's data type. The card stays PURE: it renders from supplied metadata
|
|
4
|
+
// and never touches the network. CORS forbids reading cross-origin HTML in the
|
|
5
|
+
// browser, so there is intentionally NO built-in network implementation here — an
|
|
6
|
+
// app opts in with `configureLinkPreview({ fetchMetadata })` pointing at its OWN
|
|
7
|
+
// backend/proxy. See docs/superpowers/specs/2026-06-13-kc-link-embed-cards-design.md.
|
|
8
|
+
import type { CardEnvelope } from './card-contract';
|
|
9
|
+
|
|
10
|
+
/** Rich link / Open-Graph preview payload. The card renders from this; it never fetches. */
|
|
11
|
+
export interface LinkCardData {
|
|
12
|
+
/** Canonical destination; opened via the contract `open` verb. */
|
|
13
|
+
url: string;
|
|
14
|
+
/** og:title — falls back to the domain. */
|
|
15
|
+
title?: string;
|
|
16
|
+
/** og:description — clamped to 3 lines. */
|
|
17
|
+
description?: string;
|
|
18
|
+
/** og:image — degrades gracefully when missing/broken. */
|
|
19
|
+
image?: string;
|
|
20
|
+
/** Alt for the preview image (defaults to title / decorative). */
|
|
21
|
+
imageAlt?: string;
|
|
22
|
+
/** Site favicon. */
|
|
23
|
+
favicon?: string;
|
|
24
|
+
/** Display domain; derived from `url` when omitted. */
|
|
25
|
+
domain?: string;
|
|
26
|
+
/** og:site_name; preferred over `domain` when present. */
|
|
27
|
+
siteName?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The full envelope an agent/server emits for a link card. */
|
|
31
|
+
export type LinkCardEnvelope = CardEnvelope<'link', LinkCardData>;
|
|
32
|
+
|
|
33
|
+
/** The `type` discriminator for link cards. */
|
|
34
|
+
export const LINK_CARD_TYPE = 'link' as const;
|
|
35
|
+
|
|
36
|
+
/** App-supplied resolver: a bare URL → (partial) OG metadata. Usually hits YOUR backend. */
|
|
37
|
+
export type LinkMetadataFetcher = (url: string) => Promise<Partial<LinkCardData>>;
|
|
38
|
+
|
|
39
|
+
let fetcher: LinkMetadataFetcher | undefined;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* App opt-in: supply a function (usually hitting YOUR backend/proxy) that resolves
|
|
43
|
+
* a bare URL to OG metadata. CORS forbids reading cross-origin HTML in the browser,
|
|
44
|
+
* so there is intentionally NO default network implementation.
|
|
45
|
+
*/
|
|
46
|
+
export function configureLinkPreview(opts: { fetchMetadata: LinkMetadataFetcher }): void {
|
|
47
|
+
fetcher = opts.fetchMetadata;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** True when an app has registered a fetcher (the bare-URL path is available). */
|
|
51
|
+
export function hasLinkPreviewFetcher(): boolean {
|
|
52
|
+
return fetcher !== undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Used by LinkCard ONLY when the envelope lacks metadata AND a fetcher is set.
|
|
57
|
+
* Returns the merged metadata or throws (card shows its fallback/error state).
|
|
58
|
+
*/
|
|
59
|
+
export async function resolveLinkMetadata(url: string): Promise<Partial<LinkCardData>> {
|
|
60
|
+
if (!fetcher) throw new Error('No link-preview fetcher configured');
|
|
61
|
+
return fetcher(url);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Test-only: clear the configured fetcher so tests stay isolated. */
|
|
65
|
+
export function __resetLinkPreviewForTests(): void {
|
|
66
|
+
fetcher = undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Derive a clean display domain from a URL (strips a leading `www.`). `undefined` if unparseable. */
|
|
70
|
+
export function deriveDomain(url: string): string | undefined {
|
|
71
|
+
try {
|
|
72
|
+
return new URL(url).hostname.replace(/^www\./, '');
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const RENDERABLE_SCHEMES = ['http:', 'https:'];
|
|
79
|
+
|
|
80
|
+
/** True when `url` is a syntactically valid http(s) URL (the only renderable link schemes). */
|
|
81
|
+
export function isRenderableLink(url: string): boolean {
|
|
82
|
+
try {
|
|
83
|
+
return RENDERABLE_SCHEMES.includes(new URL(url).protocol);
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|