andesine 0.1.0 → 0.4.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +27 -8
  3. package/dist/cli.js +1611 -121
  4. package/dist/cli.js.map +1 -1
  5. package/dist/config.schema.json +20 -3
  6. package/dist/templates/pages/.env.example +5 -0
  7. package/dist/templates/pages/AGENTS.md +79 -0
  8. package/dist/templates/pages/LICENSE +21 -0
  9. package/dist/templates/pages/README.md +72 -0
  10. package/dist/templates/pages/andesine.config.ts +39 -0
  11. package/dist/templates/pages/astro.config.ts +23 -0
  12. package/dist/templates/pages/gitignore +13 -0
  13. package/dist/templates/pages/package.template.json +45 -0
  14. package/dist/templates/pages/src/api/openapi.yaml +246 -0
  15. package/dist/templates/pages/src/assets/andesine.svg +1 -0
  16. package/dist/templates/pages/src/assets/andesine.ts +6 -0
  17. package/dist/templates/pages/src/assets/favicon.svg +13 -0
  18. package/dist/templates/pages/src/assets/social-dots.svg +14 -0
  19. package/dist/templates/pages/src/components/content/callout.astro +26 -0
  20. package/dist/templates/pages/src/components/content/card-grid.astro +1 -0
  21. package/dist/templates/pages/src/components/content/card.astro +31 -0
  22. package/dist/templates/pages/src/components/content/code-block.astro +34 -0
  23. package/dist/templates/pages/src/components/content/code-group.astro +13 -0
  24. package/dist/templates/pages/src/components/content/copy-button.tsx +53 -0
  25. package/dist/templates/pages/src/components/content/disclosure.astro +26 -0
  26. package/dist/templates/pages/src/components/content/figure.astro +10 -0
  27. package/dist/templates/pages/src/components/content/file-tree.astro +47 -0
  28. package/dist/templates/pages/src/components/content/index.ts +30 -0
  29. package/dist/templates/pages/src/components/content/steps.astro +16 -0
  30. package/dist/templates/pages/src/components/content/tab.astro +7 -0
  31. package/dist/templates/pages/src/components/content/tabs.astro +15 -0
  32. package/dist/templates/pages/src/components/content/tabs.tsx +99 -0
  33. package/dist/templates/pages/src/components/effects/page-scroll-shadow.tsx +27 -0
  34. package/dist/templates/pages/src/components/effects/page-scrollbar.tsx +35 -0
  35. package/dist/templates/pages/src/components/layout/call-to-action.tsx +28 -0
  36. package/dist/templates/pages/src/components/layout/footer.astro +44 -0
  37. package/dist/templates/pages/src/components/layout/group-label.tsx +34 -0
  38. package/dist/templates/pages/src/components/layout/header.astro +53 -0
  39. package/dist/templates/pages/src/components/layout/mobile-navigation.tsx +51 -0
  40. package/dist/templates/pages/src/components/layout/navigation-links.tsx +57 -0
  41. package/dist/templates/pages/src/components/layout/navigation-tree.tsx +138 -0
  42. package/dist/templates/pages/src/components/layout/outline.tsx +162 -0
  43. package/dist/templates/pages/src/components/layout/page-actions.tsx +87 -0
  44. package/dist/templates/pages/src/components/layout/powered-by.tsx +29 -0
  45. package/dist/templates/pages/src/components/layout/section-tabs.tsx +83 -0
  46. package/dist/templates/pages/src/components/layout/sidebar.tsx +65 -0
  47. package/dist/templates/pages/src/components/primitives/button.tsx +121 -0
  48. package/dist/templates/pages/src/components/primitives/card.tsx +25 -0
  49. package/dist/templates/pages/src/components/primitives/dialog.tsx +47 -0
  50. package/dist/templates/pages/src/components/primitives/input.tsx +22 -0
  51. package/dist/templates/pages/src/components/primitives/logo.tsx +41 -0
  52. package/dist/templates/pages/src/components/primitives/menu.tsx +121 -0
  53. package/dist/templates/pages/src/components/primitives/scroll-area.tsx +103 -0
  54. package/dist/templates/pages/src/components/primitives/shortcut.tsx +60 -0
  55. package/dist/templates/pages/src/components/primitives/tooltip.tsx +39 -0
  56. package/dist/templates/pages/src/components/reference/facts.astro +27 -0
  57. package/dist/templates/pages/src/components/reference/fields.astro +102 -0
  58. package/dist/templates/pages/src/components/reference/method-badge.astro +18 -0
  59. package/dist/templates/pages/src/components/reference/methods.ts +17 -0
  60. package/dist/templates/pages/src/components/reference/operation.astro +252 -0
  61. package/dist/templates/pages/src/components/reference/styles.ts +14 -0
  62. package/dist/templates/pages/src/components/search/highlighted-text.tsx +22 -0
  63. package/dist/templates/pages/src/components/search/search-answer.tsx +123 -0
  64. package/dist/templates/pages/src/components/search/search-palette.tsx +174 -0
  65. package/dist/templates/pages/src/components/search/search-results.tsx +229 -0
  66. package/dist/templates/pages/src/components/social/card.ts +77 -0
  67. package/dist/templates/pages/src/content/docs/customization/index.mdx +11 -0
  68. package/dist/templates/pages/src/content/docs/customization/styles.mdx +18 -0
  69. package/dist/templates/pages/src/content/docs/getting-started/api-reference.mdx +100 -0
  70. package/dist/templates/pages/src/content/docs/getting-started/components.mdx +156 -0
  71. package/dist/templates/pages/src/content/docs/getting-started/configuration.mdx +194 -0
  72. package/dist/templates/pages/src/content/docs/getting-started/deployment.mdx +142 -0
  73. package/dist/templates/pages/src/content/docs/getting-started/index.mdx +22 -0
  74. package/dist/templates/pages/src/content/docs/getting-started/writing-pages.mdx +35 -0
  75. package/dist/templates/pages/src/content/docs/index.mdx +21 -0
  76. package/dist/templates/pages/src/content.config.ts +1 -0
  77. package/dist/templates/pages/src/layouts/page.astro +195 -0
  78. package/dist/templates/pages/src/styles/global.css +95 -0
  79. package/dist/templates/pages/tsconfig.json +10 -0
  80. package/dist/templates/pages/uno.config.ts +168 -0
  81. package/package.json +2 -2
@@ -0,0 +1,102 @@
1
+ ---
2
+ import type { FieldView, VariantView } from "@andesine/pages";
3
+ import Facts from "./facts.astro";
4
+ import { descriptionClass, labelClass, nameClass, requiredClass } from "./styles";
5
+
6
+ interface Props {
7
+ fields: FieldView[];
8
+ variants?: VariantView[];
9
+ expanded?: boolean;
10
+ }
11
+
12
+ const { fields, variants = [], expanded = false } = Astro.props;
13
+ const kindLabels = { oneOf: "One of", anyOf: "Any of" };
14
+ // A schema can have both kinds; each gets its own label.
15
+ const variantGroups = (["oneOf", "anyOf"] as const)
16
+ .map((kind) => variants.filter((variant) => variant.kind === kind))
17
+ .filter((group) => group.length > 0);
18
+ // Nested fields have no card: a toggle, and a line below its chevron.
19
+ const toggleClass =
20
+ "flex w-fit cursor-pointer list-none items-center gap-1.5 py-0.5 text-sm font-medium text-gray-500 transition duration-200 ease-out @hover:text-gray-700 focus-visible:text-gray-700 [&::-webkit-details-marker]:hidden";
21
+ const chevronClass =
22
+ "i-lucide:chevron-right h-3.5 w-3.5 shrink-0 transition-transform duration-200 [[open]>summary>&]:rotate-90";
23
+ const nestedClass = "ml-[0.4375rem] mt-2 border-l border-gray-200 pl-4";
24
+ const countLabel = (count: number): string => `${count} ${count === 1 ? "field" : "fields"}`;
25
+ ---
26
+
27
+ {
28
+ fields.length > 0 && (
29
+ <div role="list" class="divide-y divide-gray-200">
30
+ {fields.map((field) => {
31
+ const nestedCount = field.fields.length + field.variants.length;
32
+
33
+ return (
34
+ <div
35
+ role="listitem"
36
+ id={field.id}
37
+ class="flex scroll-mt-30 flex-col gap-1 py-3 first:pt-0 last:pb-0"
38
+ >
39
+ <div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
40
+ <code class={nameClass}>
41
+ {field.name}
42
+ {field.required && (
43
+ <span title="Required" class={requiredClass}>
44
+ *
45
+ </span>
46
+ )}
47
+ </code>
48
+ <span class="font-mono text-xs text-gray-500">{field.type}</span>
49
+ {field.deprecated && (
50
+ <span class="text-sm font-medium text-amber-600">deprecated</span>
51
+ )}
52
+ {field.readOnly && <span class="text-sm text-gray-400">read-only</span>}
53
+ {field.writeOnly && <span class="text-sm text-gray-400">write-only</span>}
54
+ </div>
55
+ {field.description && <div class={descriptionClass} set:html={field.description} />}
56
+ <Facts facts={field.facts} />
57
+ {field.recursive && (
58
+ <a
59
+ href={field.recursive.href}
60
+ class="not-prose self-start text-sm font-medium text-gray-500 underline decoration-gray-300 underline-offset-2 @hover:text-gray-700"
61
+ >
62
+ Repeats {field.recursive.name}
63
+ </a>
64
+ )}
65
+ {nestedCount > 0 && (
66
+ <details open={expanded}>
67
+ <summary class={toggleClass}>
68
+ <span aria-hidden="true" class={chevronClass} />
69
+ {field.fields.length > 0 ? countLabel(field.fields.length) : "Options"}
70
+ </summary>
71
+ <div class={nestedClass}>
72
+ <Astro.self fields={field.fields} variants={field.variants} />
73
+ </div>
74
+ </details>
75
+ )}
76
+ </div>
77
+ );
78
+ })}
79
+ </div>
80
+ )
81
+ }
82
+ {
83
+ variantGroups.map((group, index) => (
84
+ <div class:list={["flex flex-col gap-1", (fields.length > 0 || index > 0) && "mt-3"]}>
85
+ <p class={labelClass}>{kindLabels[group[0]!.kind]}</p>
86
+ {group.map((variant) => (
87
+ <details id={variant.id} class="scroll-mt-30">
88
+ <summary class={toggleClass}>
89
+ <span aria-hidden="true" class={chevronClass} />
90
+ <span class="font-mono text-gray-700">{variant.label}</span>
91
+ <span class="font-mono text-xs font-normal">{variant.type}</span>
92
+ </summary>
93
+ <div class:list={[nestedClass, "flex flex-col gap-2"]}>
94
+ {variant.description && <div class={descriptionClass} set:html={variant.description} />}
95
+ <Facts facts={variant.facts} />
96
+ <Astro.self fields={variant.fields} variants={variant.variants} />
97
+ </div>
98
+ </details>
99
+ ))}
100
+ </div>
101
+ ))
102
+ }
@@ -0,0 +1,18 @@
1
+ ---
2
+ import { getMethodColor } from "./methods";
3
+
4
+ interface Props {
5
+ method: string;
6
+ }
7
+
8
+ const { method } = Astro.props;
9
+ ---
10
+
11
+ <span
12
+ class:list={[
13
+ "shrink-0 rounded-md bg-gray-100 font-mono text-sm font-bold uppercase",
14
+ getMethodColor(method)
15
+ ]}
16
+ >
17
+ {method}
18
+ </span>
@@ -0,0 +1,17 @@
1
+ /** Text colors of HTTP methods, in the badges and the navigation. */
2
+ const methodColors: Record<string, string> = {
3
+ get: "text-emerald-500",
4
+ post: "text-sky-500",
5
+ put: "text-amber-500",
6
+ patch: "text-orange-500",
7
+ delete: "text-red-500",
8
+ head: "text-violet-500",
9
+ options: "text-cyan-500"
10
+ };
11
+ const methodLabels: Record<string, string> = { delete: "DEL", options: "OPT" };
12
+
13
+ const getMethodColor = (method: string): string => methodColors[method] ?? "text-gray-500";
14
+ /** A short label that fits the navigation, e.g. `DEL` for `delete`. */
15
+ const getMethodLabel = (method: string): string => methodLabels[method] ?? method.toUpperCase();
16
+
17
+ export { getMethodColor, getMethodLabel };
@@ -0,0 +1,252 @@
1
+ ---
2
+ import type { CodeView, MediaView } from "@andesine/pages";
3
+ import { getOperationView, type OperationProps } from "@andesine/pages/astro";
4
+ import Callout from "../content/callout.astro";
5
+ import CodeBlock from "../content/code-block.astro";
6
+ import CodeGroup from "../content/code-group.astro";
7
+ import { CopyButton } from "../content/copy-button";
8
+ import { ScrollArea } from "../primitives/scroll-area";
9
+ import Fields from "./fields.astro";
10
+ import MethodBadge from "./method-badge.astro";
11
+ import { descriptionClass, labelClass, nameClass, requiredClass } from "./styles";
12
+
13
+ /** A reference page passes `view`; an embed passes `source` and `id`, like the MDX component. */
14
+ type Props = OperationProps;
15
+
16
+ const view = await getOperationView(Astro.props);
17
+ // Embeds sit under the headings of their page.
18
+ const Heading = view.embedded ? "h3" : "h2";
19
+ const [server] = view.servers;
20
+ const serverURL = server?.url.replace(/\/$/, "") ?? "";
21
+ const successStatus = view.responses.find((response) => response.status.startsWith("2"))?.status;
22
+ const headingClass = view.embedded
23
+ ? "not-prose scroll-mt-30 text-lg font-semibold text-gray-900"
24
+ : "not-prose scroll-mt-30 text-xl font-semibold text-gray-900";
25
+ const toMediaTypes = (contents: MediaView[]): string => {
26
+ return contents.map((media) => media.type).join(", ");
27
+ };
28
+ const toLabels = (examples: CodeView[]): string => {
29
+ return JSON.stringify(examples.map((example) => example.label));
30
+ };
31
+ ---
32
+
33
+ <div
34
+ class:list={[
35
+ "grid gap-8",
36
+ view.embedded
37
+ ? "my-6 border-y border-gray-200 py-6"
38
+ : "mt-4 xl:grid-cols-[minmax(0,1fr)_minmax(22rem,0.8fr)] xl:items-start"
39
+ ]}
40
+ >
41
+ <div class="flex min-w-0 flex-col gap-8">
42
+ <div class="flex flex-col gap-3">
43
+ {
44
+ view.embedded && (
45
+ <a
46
+ href={view.href}
47
+ class="not-prose group flex w-fit items-center gap-1.5 text-lg font-semibold text-gray-900"
48
+ >
49
+ {view.title}
50
+ <span
51
+ aria-hidden="true"
52
+ class="i-lucide:arrow-up-right h-4.5 w-4.5 text-gray-400 transition duration-200 ease-out group-hover:text-gray-700"
53
+ />
54
+ </a>
55
+ )
56
+ }
57
+ {
58
+ view.deprecated && (
59
+ <div class="[&>aside]:my-0">
60
+ <Callout type="warning">
61
+ This operation is deprecated. Do not use it in new code.
62
+ </Callout>
63
+ </div>
64
+ )
65
+ }
66
+ <div
67
+ class="not-prose flex min-w-0 items-center gap-2 h-9 rounded-xl bg-gray-100 pl-3 pr-1.25"
68
+ >
69
+ <MethodBadge method={view.method} />
70
+ {/* Long URLs scroll on one line; the badge and the copy button stay in place. */}
71
+ <ScrollArea
72
+ client:visible
73
+ orientation="horizontal"
74
+ class="min-w-0 flex-1 self-stretch"
75
+ contentClass="flex h-full items-center"
76
+ scrollbarClass="h-1.5 px-0"
77
+ thumbClass="border-0"
78
+ >
79
+ <code class="whitespace-nowrap font-mono text-sm text-gray-700">
80
+ {serverURL && <span class="text-gray-400">{serverURL}</span>}{view.path}
81
+ </code>
82
+ </ScrollArea>
83
+ <div class="flex shrink-0 items-center">
84
+ <CopyButton client:visible text={`${serverURL}${view.path}`} label="Copy URL" />
85
+ </div>
86
+ </div>
87
+ {view.description && <div class:list={descriptionClass} set:html={view.description} />}
88
+ </div>
89
+ {
90
+ view.security.length > 0 && (
91
+ <section class="flex flex-col gap-3">
92
+ <Heading id={`${view.anchor}authentication`} class={headingClass}>
93
+ Authentication
94
+ </Heading>
95
+ <div role="list" class="divide-y divide-gray-200">
96
+ {view.security.map((requirement) => (
97
+ <div role="listitem" class="flex flex-col gap-1 py-3 first:pt-0 last:pb-0">
98
+ {requirement.length === 0 && (
99
+ <span class="text-base text-gray-500">No authentication</span>
100
+ )}
101
+ {requirement.map((scheme) => (
102
+ <div class="flex flex-col gap-1">
103
+ <div class="flex flex-wrap items-baseline gap-x-2">
104
+ <code class={nameClass}>{scheme.id}</code>
105
+ <span class="font-mono text-xs text-gray-500">{scheme.label}</span>
106
+ </div>
107
+ {scheme.description && (
108
+ <div class:list={descriptionClass} set:html={scheme.description} />
109
+ )}
110
+ {scheme.scopes.length > 0 && (
111
+ <p class="not-prose text-sm text-gray-500">
112
+ Scopes: {scheme.scopes.join(", ")}
113
+ </p>
114
+ )}
115
+ </div>
116
+ ))}
117
+ </div>
118
+ ))}
119
+ </div>
120
+ </section>
121
+ )
122
+ }
123
+ {
124
+ view.parameters.map((group) => (
125
+ <section class="flex flex-col gap-3">
126
+ <Heading id={group.id} class={headingClass}>
127
+ {group.label}
128
+ </Heading>
129
+ <Fields fields={group.fields} />
130
+ </section>
131
+ ))
132
+ }
133
+ {
134
+ view.requestBody && (
135
+ <section class="flex flex-col gap-3">
136
+ <div class="flex flex-wrap items-baseline justify-between gap-x-2">
137
+ <Heading id={view.requestBody.id} class={headingClass}>
138
+ Request body
139
+ {view.requestBody.required && (
140
+ <span title="Required" class={requiredClass}>
141
+ *
142
+ </span>
143
+ )}
144
+ </Heading>
145
+ <span class:list={[labelClass, "font-mono"]}>
146
+ {toMediaTypes(view.requestBody.contents)}
147
+ </span>
148
+ </div>
149
+ {view.requestBody.description && (
150
+ <div class:list={descriptionClass} set:html={view.requestBody.description} />
151
+ )}
152
+ {view.requestBody.contents.map((media, _, contents) => (
153
+ <div id={media.id} class="flex flex-col gap-3">
154
+ {contents.length > 1 && <p class:list={[labelClass, "font-mono"]}>{media.type}</p>}
155
+ <Fields fields={media.schema ? [media.schema] : []} expanded />
156
+ </div>
157
+ ))}
158
+ </section>
159
+ )
160
+ }
161
+ {
162
+ view.responses.length > 0 && (
163
+ <section class="flex flex-col gap-3">
164
+ <Heading id={`${view.anchor}responses`} class={headingClass}>
165
+ Responses
166
+ </Heading>
167
+ <div class="flex flex-col gap-2">
168
+ {view.responses.map((response) => {
169
+ const hasBody = response.headers.length + response.contents.length > 0;
170
+
171
+ return (
172
+ <details
173
+ id={response.id}
174
+ open={response.status === successStatus}
175
+ class="scroll-mt-30 overflow-hidden rounded-xl border border-gray-200 bg-white"
176
+ >
177
+ <summary class="flex cursor-pointer list-none items-center gap-2 px-3 py-2 transition duration-200 ease-out @hover:bg-gray-100 focus-visible:bg-gray-100 [&::-webkit-details-marker]:hidden">
178
+ <span
179
+ aria-hidden="true"
180
+ class="i-lucide:chevron-right h-4 w-4 shrink-0 text-gray-400 transition-transform duration-200 [[open]>summary>&]:rotate-90"
181
+ />
182
+ <span class="font-mono text-sm font-semibold text-gray-900">
183
+ {response.status}
184
+ </span>
185
+ {response.label && (
186
+ <span class="text-base text-gray-500">{response.label}</span>
187
+ )}
188
+ <span class:list={[labelClass, "ml-auto font-mono"]}>
189
+ {toMediaTypes(response.contents)}
190
+ </span>
191
+ </summary>
192
+ <div class="flex flex-col gap-3 border-t border-gray-200 px-3 py-3">
193
+ {response.description && (
194
+ <div class:list={descriptionClass} set:html={response.description} />
195
+ )}
196
+ {response.headers.length > 0 && (
197
+ <div class="flex flex-col gap-2">
198
+ <p class={labelClass}>Headers</p>
199
+ <Fields fields={response.headers} />
200
+ </div>
201
+ )}
202
+ {response.contents.map((media, _, contents) => (
203
+ <div id={media.id} class="flex flex-col gap-3">
204
+ {contents.length > 1 && (
205
+ <p class:list={[labelClass, "font-mono"]}>{media.type}</p>
206
+ )}
207
+ <Fields fields={media.schema ? [media.schema] : []} expanded />
208
+ </div>
209
+ ))}
210
+ {!hasBody && !response.description && (
211
+ <p class="not-prose text-base text-gray-500">No content.</p>
212
+ )}
213
+ </div>
214
+ </details>
215
+ );
216
+ })}
217
+ </div>
218
+ </section>
219
+ )
220
+ }
221
+ </div>
222
+ {
223
+ view.examples.length + view.responseExamples.length > 0 && (
224
+ <aside aria-label="Examples" class="order-first flex min-w-0 flex-col gap-4 xl:order-none">
225
+ {view.examples.length > 0 && (
226
+ <div class="flex flex-col gap-1">
227
+ <p class={labelClass}>Request</p>
228
+ <CodeGroup values={toLabels(view.examples)}>
229
+ {(index: number) => (
230
+ <CodeBlock data-language={view.examples[index]!.language}>
231
+ <Fragment set:html={view.examples[index]!.html} />
232
+ </CodeBlock>
233
+ )}
234
+ </CodeGroup>
235
+ </div>
236
+ )}
237
+ {view.responseExamples.length > 0 && (
238
+ <div class="flex flex-col gap-1">
239
+ <p class={labelClass}>Response</p>
240
+ <CodeGroup values={toLabels(view.responseExamples)}>
241
+ {(index: number) => (
242
+ <CodeBlock data-language={view.responseExamples[index]!.language}>
243
+ <Fragment set:html={view.responseExamples[index]!.html} />
244
+ </CodeBlock>
245
+ )}
246
+ </CodeGroup>
247
+ </div>
248
+ )}
249
+ </aside>
250
+ )
251
+ }
252
+ </div>
@@ -0,0 +1,14 @@
1
+ /** Small section labels, like the group labels of the search palette. */
2
+ const labelClass = "not-prose px-1 py-0.5 text-xs leading-normal text-gray-400";
3
+
4
+ /** Names of fields and security schemes; unlike values, not styled as inline code. */
5
+ const nameClass = "not-prose font-mono text-sm font-semibold text-gray-900";
6
+
7
+ /** Markdown descriptions, without the outer margins of their first and last blocks. */
8
+ const descriptionClass =
9
+ "prose max-w-none text-gray-600 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0";
10
+
11
+ /** Marks required fields, after their names. */
12
+ const requiredClass = "font-semibold text-tertiary";
13
+
14
+ export { labelClass, nameClass, descriptionClass, requiredClass };
@@ -0,0 +1,22 @@
1
+ import { splitMatches } from "@andesine/ui/solid";
2
+ import { type Component, For, Show } from "solid-js";
3
+
4
+ interface HighlightedTextProps {
5
+ text: string;
6
+ query: string;
7
+ }
8
+
9
+ /** Shows matches of the query with gradient text, as in the Andesine app. */
10
+ const HighlightedText: Component<HighlightedTextProps> = (props) => (
11
+ <For each={splitMatches(props.text, props.query)}>
12
+ {(part) => (
13
+ <Show when={part.match} fallback={part.text}>
14
+ <mark class="bg-transparent bg-gradient-to-tr from-secondary via-primary to-secondary bg-clip-text font-medium text-transparent">
15
+ {part.text}
16
+ </mark>
17
+ </Show>
18
+ )}
19
+ </For>
20
+ );
21
+
22
+ export { HighlightedText };
@@ -0,0 +1,123 @@
1
+ import { type Answer, groupAnswerSources, renderAnswer } from "@andesine/pages/client";
2
+ import type { AnswerTurn } from "@andesine/ui/solid";
3
+ import clsx from "clsx";
4
+ import { type Component, For, Index, Show } from "solid-js";
5
+ import { ResultLink, SearchSkeleton } from "./search-results";
6
+
7
+ interface SearchAnswerProps {
8
+ turns: Array<AnswerTurn<Answer>>;
9
+ /** Called when a link in an answer opens a page. */
10
+ onOpen(): void;
11
+ }
12
+
13
+ interface AnswerTurnViewProps {
14
+ turn: AnswerTurn<Answer>;
15
+ onOpen(): void;
16
+ }
17
+
18
+ const lineWidths = ["w-full", "w-3/5", "w-4/5"];
19
+
20
+ const getErrorMessage = (error: unknown): string => {
21
+ return error instanceof Error && error.message ? error.message : "Try again later.";
22
+ };
23
+ const TextSkeleton: Component = () => (
24
+ <div aria-hidden="true" class="flex w-full max-w-9/10 flex-col gap-1.5 pl-1">
25
+ <For each={lineWidths}>
26
+ {(width) => <span class={clsx("h-3.5 animate-pulse rounded-sm bg-gray-200", width)} />}
27
+ </For>
28
+ </div>
29
+ );
30
+ const AnswerTurnView: Component<AnswerTurnViewProps> = (props) => {
31
+ const answer = (): Answer | undefined => props.turn.answer;
32
+ const loading = (): boolean => props.turn.status === "loading";
33
+ // Pages open with a full navigation, but links to the current page only change the hash.
34
+ const onClick = (event: MouseEvent): void => {
35
+ const link = (event.target as Element).closest("a");
36
+
37
+ if (link && !link.target) props.onOpen();
38
+ };
39
+
40
+ return (
41
+ <div class="flex flex-col gap-2" onClick={onClick}>
42
+ <div class="flex justify-end">
43
+ <p class="max-w-9/10 whitespace-pre-wrap rounded-lg bg-gray-100 px-2 py-1 text-sm leading-relaxed">
44
+ {props.turn.question}
45
+ </p>
46
+ </div>
47
+ <Show
48
+ when={answer()?.text}
49
+ fallback={
50
+ <Show when={loading()}>
51
+ <TextSkeleton />
52
+ </Show>
53
+ }
54
+ >
55
+ <div
56
+ class="prose prose-sm max-w-9/10 break-words pl-1 text-sm leading-relaxed [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
57
+ innerHTML={renderAnswer(answer()!)}
58
+ />
59
+ </Show>
60
+ <Show when={loading() && !answer()?.sourcesReceived}>
61
+ <SearchSkeleton compact />
62
+ </Show>
63
+ <Show when={answer()?.sources.length}>
64
+ <ul aria-label="Answer sources" class="m-0 flex list-none flex-col gap-0.5 p-0">
65
+ <For each={groupAnswerSources(answer()!.sources)}>
66
+ {(group) => (
67
+ <li class="flex items-start gap-1">
68
+ <div class="min-w-0 flex-1">
69
+ <ResultLink
70
+ href={group.href}
71
+ label={group.sources[0]!.title || "Untitled"}
72
+ sublabel={group.sources[0]!.collectionPath.join(" › ") || undefined}
73
+ onClick={() => props.onOpen()}
74
+ />
75
+ </div>
76
+ <div class="flex max-w-1/2 shrink-0 flex-wrap justify-end gap-1 py-1 pr-1">
77
+ <For each={group.sources}>
78
+ {(source) => {
79
+ const section = source.headingPath.join(" › ") || source.title;
80
+
81
+ return (
82
+ <a
83
+ href={source.href}
84
+ title={section}
85
+ aria-label={`Reference ${source.id}: ${source.title}, ${section}`}
86
+ class="font-mono text-xs text-gray-400 focus-visible:(outline outline-2 outline-primary) media-mouse:hover:(text-gray-600 underline)"
87
+ >
88
+ [{source.id}]
89
+ </a>
90
+ );
91
+ }}
92
+ </For>
93
+ </div>
94
+ </li>
95
+ )}
96
+ </For>
97
+ </ul>
98
+ </Show>
99
+ <Show when={props.turn.status === "error"}>
100
+ <div
101
+ role="alert"
102
+ class="flex min-h-24 flex-col items-center justify-center gap-2 text-center"
103
+ >
104
+ <span aria-hidden="true" class="i-lucide:triangle-alert h-6 w-6 text-red-500" />
105
+ <span class="flex flex-col gap-0.5">
106
+ <span class="text-sm font-medium">Ask AI failed</span>
107
+ <span class="max-w-sm text-xs text-gray-400">{getErrorMessage(props.turn.error)}</span>
108
+ </span>
109
+ </div>
110
+ </Show>
111
+ </div>
112
+ );
113
+ };
114
+ /** The AI answer conversation, after the Andesine app's search answer. */
115
+ const SearchAnswer: Component<SearchAnswerProps> = (props) => (
116
+ <div class="flex flex-col gap-4">
117
+ <Index each={props.turns}>
118
+ {(turn) => <AnswerTurnView turn={turn()} onOpen={props.onOpen} />}
119
+ </Index>
120
+ </div>
121
+ );
122
+
123
+ export { SearchAnswer };