@wrikka/create-docs 0.1.0 → 0.2.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.
@@ -0,0 +1,374 @@
1
+ import { useNavigate, useSearch } from "@tanstack/solid-router";
2
+ import {
3
+ createEffect,
4
+ createMemo,
5
+ createResource,
6
+ createSignal,
7
+ For,
8
+ Show,
9
+ } from "solid-js";
10
+ import { useDocs } from "../context";
11
+ import { searchDocs, useCollections } from "../data";
12
+ import type { SearchResult } from "../types";
13
+
14
+ /** Split text into highlighted parts matching any query term. */
15
+ function highlightParts(text: string, query: string) {
16
+ const terms = query
17
+ .trim()
18
+ .split(/\s+/)
19
+ .filter(Boolean)
20
+ .map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
21
+ if (!terms.length || !text) return [{ text, match: false }];
22
+ const re = new RegExp(`(${terms.join("|")})`, "gi");
23
+ const parts: { text: string; match: boolean }[] = [];
24
+ let last = 0;
25
+ for (const m of text.matchAll(re)) {
26
+ const i = m.index ?? 0;
27
+ if (i > last) parts.push({ text: text.slice(last, i), match: false });
28
+ parts.push({ text: m[0], match: true });
29
+ last = i + m[0].length;
30
+ }
31
+ if (last < text.length) parts.push({ text: text.slice(last), match: false });
32
+ return parts;
33
+ }
34
+
35
+ function Highlighted(props: { text: string; query: string }) {
36
+ return (
37
+ <For each={highlightParts(props.text, props.query)}>
38
+ {(p) =>
39
+ p.match ? (
40
+ <mark class="bg-warning/30 text-foreground rounded-sm px-0.5">
41
+ {p.text}
42
+ </mark>
43
+ ) : (
44
+ p.text
45
+ )
46
+ }
47
+ </For>
48
+ );
49
+ }
50
+
51
+ function stripMarkdown(md: string): string {
52
+ return md
53
+ .replace(/```[\s\S]*?```/g, " [code block] ")
54
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, "")
55
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
56
+ .replace(/^#{1,6}\s+/gm, "")
57
+ .replace(/^>\s?/gm, "")
58
+ .replace(/[*_`~]/g, "")
59
+ .trim();
60
+ }
61
+
62
+ /** Extract context windows around query matches in content. */
63
+ function matchExcerpts(content: string, query: string, max = 5): string[] {
64
+ const terms = query.trim().split(/\s+/).filter(Boolean);
65
+ if (!terms.length) return [];
66
+ const plain = stripMarkdown(content);
67
+ const re = new RegExp(
68
+ `(${terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`,
69
+ "gi",
70
+ );
71
+ const excerpts: string[] = [];
72
+ for (const m of plain.matchAll(re)) {
73
+ const i = m.index ?? 0;
74
+ const start = Math.max(0, i - 80);
75
+ const end = Math.min(plain.length, i + m[0].length + 120);
76
+ excerpts.push(
77
+ `${start > 0 ? "…" : ""}${plain.slice(start, end)}${end < plain.length ? "…" : ""}`,
78
+ );
79
+ if (excerpts.length >= max) break;
80
+ }
81
+ return excerpts;
82
+ }
83
+
84
+ export function SearchPage() {
85
+ const config = useDocs();
86
+ const navigate = useNavigate();
87
+ const collections = useCollections();
88
+ const search = useSearch({ strict: false }) as () => { q?: string };
89
+ const [query, setQuery] = createSignal(search().q ?? "");
90
+ const [collection, setCollection] = createSignal<string | null>(null);
91
+ const [selected, setSelected] = createSignal(0);
92
+
93
+ const [results] = createResource(query, async (q) => {
94
+ const term = q.trim();
95
+ if (term.length < 2) return [] as SearchResult[];
96
+ try {
97
+ return await searchDocs(config, term);
98
+ } catch {
99
+ return [] as SearchResult[];
100
+ }
101
+ });
102
+
103
+ const filtered = createMemo(() => {
104
+ const c = collection();
105
+ const list = results() ?? [];
106
+ return c ? list.filter((r) => r.collection === c) : list;
107
+ });
108
+
109
+ const groups = createMemo((): [string, SearchResult[], number][] => {
110
+ const map = new Map<string, SearchResult[]>();
111
+ for (const r of filtered()) {
112
+ const g = map.get(r.collection) ?? [];
113
+ g.push(r);
114
+ map.set(r.collection, g);
115
+ }
116
+ let offset = 0;
117
+ return [...map.entries()].map(([col, items]) => {
118
+ const entry: [string, SearchResult[], number] = [col, items, offset];
119
+ offset += items.length;
120
+ return entry;
121
+ });
122
+ });
123
+
124
+ const flat = () => filtered();
125
+ const current = () => flat()[selected()];
126
+
127
+ const [preview] = createResource(
128
+ () => {
129
+ const r = current();
130
+ return r ? { collection: r.collection, id: r.id } : null;
131
+ },
132
+ async (sel) => {
133
+ try {
134
+ const doc = await config.dataSource.get(sel.collection, sel.id);
135
+ return doc.content ?? "";
136
+ } catch {
137
+ return "";
138
+ }
139
+ },
140
+ );
141
+
142
+ const collectionLabel = (id: string) =>
143
+ collections()?.find((c) => c.id === id)?.label ?? id;
144
+
145
+ const open = (r: SearchResult) => {
146
+ navigate({
147
+ to: "/$collection/$docId",
148
+ params: { collection: r.collection, docId: r.id },
149
+ });
150
+ };
151
+
152
+ createEffect(() => {
153
+ setSelected(0);
154
+ });
155
+
156
+ const onKey = (e: KeyboardEvent) => {
157
+ if (e.key === "ArrowDown") {
158
+ e.preventDefault();
159
+ setSelected((i) => Math.min(i + 1, flat().length - 1));
160
+ }
161
+ if (e.key === "ArrowUp") {
162
+ e.preventDefault();
163
+ setSelected((i) => Math.max(i - 1, 0));
164
+ }
165
+ if (e.key === "Enter" && current()) open(current()!);
166
+ };
167
+
168
+ return (
169
+ <div class="max-w-7xl mx-auto px-6 py-8 pb-24" onKeyDown={onKey}>
170
+ <div class="flex items-center gap-3 mb-6">
171
+ <span class="i-mdi:magnify text-3xl text-primary" aria-hidden="true" />
172
+ <div>
173
+ <h1 class="text-2xl font-bold m-0">Search</h1>
174
+ <p class="text-muted text-sm m-0">
175
+ Full-text search across all collections.
176
+ </p>
177
+ </div>
178
+ </div>
179
+
180
+ <label class="flex items-center gap-2 px-4 h-12 rounded-xl border border-border bg-surface focus-within:border-focus transition-colors mb-4">
181
+ <span class="i-mdi:magnify text-muted text-lg" aria-hidden="true" />
182
+ <input
183
+ ref={(el) => queueMicrotask(() => el.focus())}
184
+ type="search"
185
+ value={query()}
186
+ onInput={(e) => setQuery(e.currentTarget.value)}
187
+ placeholder="Search documentation…"
188
+ aria-label="Search documentation"
189
+ class="flex-1 bg-transparent outline-none border-none text-base text-foreground placeholder:text-muted"
190
+ />
191
+ <Show when={query()}>
192
+ <button
193
+ type="button"
194
+ onClick={() => setQuery("")}
195
+ aria-label="Clear search"
196
+ class="w-7 h-7 inline-flex items-center justify-center rounded text-muted hover:text-foreground cursor-pointer border-none bg-transparent"
197
+ >
198
+ <span class="i-mdi:close" aria-hidden="true" />
199
+ </button>
200
+ </Show>
201
+ </label>
202
+
203
+ <div class="flex flex-wrap items-center gap-2 mb-6">
204
+ <button
205
+ type="button"
206
+ onClick={() => setCollection(null)}
207
+ class={`px-3 h-8 rounded-full text-xs border transition-colors cursor-pointer ${
208
+ collection() === null
209
+ ? "bg-primary text-primary-foreground border-primary"
210
+ : "bg-surface text-muted border-border hover:border-focus"
211
+ }`}
212
+ >
213
+ All
214
+ </button>
215
+ <For each={[...new Set((results() ?? []).map((r) => r.collection))]}>
216
+ {(c) => (
217
+ <button
218
+ type="button"
219
+ onClick={() => setCollection(c)}
220
+ class={`px-3 h-8 rounded-full text-xs border transition-colors cursor-pointer ${
221
+ collection() === c
222
+ ? "bg-primary text-primary-foreground border-primary"
223
+ : "bg-surface text-muted border-border hover:border-focus"
224
+ }`}
225
+ >
226
+ {collectionLabel(c)}
227
+ </button>
228
+ )}
229
+ </For>
230
+ <span class="ml-auto text-xs text-muted">
231
+ {filtered().length} result{filtered().length === 1 ? "" : "s"}
232
+ </span>
233
+ </div>
234
+
235
+ <Show
236
+ when={query().trim().length >= 2}
237
+ fallback={
238
+ <div class="flex flex-col items-center gap-3 py-20 rounded-xl border border-dashed border-border text-muted">
239
+ <span class="i-mdi:text-search text-5xl" aria-hidden="true" />
240
+ <p class="m-0 text-sm">Type at least 2 characters to search.</p>
241
+ <p class="m-0 text-xs">
242
+ Use ↑↓ to navigate results, Enter to open, or press Ctrl K for the
243
+ command palette.
244
+ </p>
245
+ </div>
246
+ }
247
+ >
248
+ <div class="grid grid-cols-1 lg:grid-cols-[320px_minmax(0,1fr)] gap-6">
249
+ {/* Results sidebar */}
250
+ <aside class="rounded-xl border border-border bg-surface/30 overflow-hidden self-start max-h-[70vh] overflow-y-auto">
251
+ <Show when={results.loading}>
252
+ <div class="p-6 text-sm text-muted text-center">Searching…</div>
253
+ </Show>
254
+ <Show when={!results.loading && filtered().length === 0}>
255
+ <div class="p-6 text-sm text-muted text-center">
256
+ No results for "{query()}"
257
+ </div>
258
+ </Show>
259
+ <For each={groups()}>
260
+ {([col, items, start]) => {
261
+ return (
262
+ <div>
263
+ <div class="px-3 py-2 text-[10px] uppercase tracking-wider font-semibold text-muted border-b border-border bg-background/60 sticky top-0">
264
+ {collectionLabel(col)}
265
+ <span class="ml-1 font-normal">({items.length})</span>
266
+ </div>
267
+ <ul class="list-none m-0 p-1">
268
+ <For each={items}>
269
+ {(r, i) => {
270
+ const idx = start + i();
271
+ return (
272
+ <li>
273
+ <button
274
+ type="button"
275
+ onClick={() => setSelected(idx)}
276
+ onDblClick={() => open(r)}
277
+ class={`w-full text-left px-3 py-2.5 rounded-md cursor-pointer border-none transition-colors ${
278
+ selected() === idx
279
+ ? "bg-primary/10"
280
+ : "bg-transparent hover:bg-surface"
281
+ }`}
282
+ >
283
+ <div class="text-sm font-medium text-foreground truncate">
284
+ <Highlighted text={r.title} query={query()} />
285
+ </div>
286
+ <Show when={r.snippet}>
287
+ <p class="text-xs text-muted m-0 mt-0.5 line-clamp-2">
288
+ <Highlighted
289
+ text={r.snippet}
290
+ query={query()}
291
+ />
292
+ </p>
293
+ </Show>
294
+ </button>
295
+ </li>
296
+ );
297
+ }}
298
+ </For>
299
+ </ul>
300
+ </div>
301
+ );
302
+ }}
303
+ </For>
304
+ </aside>
305
+
306
+ {/* Content preview with highlights */}
307
+ <section class="rounded-xl border border-border bg-surface/30 min-h-64 max-h-[70vh] overflow-y-auto">
308
+ <Show
309
+ when={current()}
310
+ fallback={
311
+ <div class="flex flex-col items-center gap-2 p-10 text-muted text-sm">
312
+ <span
313
+ class="i-mdi:file-search-outline text-4xl"
314
+ aria-hidden="true"
315
+ />
316
+ Select a result to preview matching content.
317
+ </div>
318
+ }
319
+ >
320
+ {(r) => (
321
+ <div class="p-5">
322
+ <div class="flex items-start gap-3 pb-4 mb-4 border-b border-border">
323
+ <div class="min-w-0 flex-1">
324
+ <div class="text-[10px] uppercase tracking-wide text-muted mb-1">
325
+ {collectionLabel(r().collection)}
326
+ </div>
327
+ <h2 class="text-lg font-semibold text-foreground m-0">
328
+ <Highlighted text={r().title} query={query()} />
329
+ </h2>
330
+ </div>
331
+ <button
332
+ type="button"
333
+ onClick={() => open(r())}
334
+ class="px-3 h-9 inline-flex items-center gap-1.5 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary-hover transition-colors cursor-pointer border-none shrink-0"
335
+ >
336
+ Open page
337
+ <span class="i-mdi:arrow-right" aria-hidden="true" />
338
+ </button>
339
+ </div>
340
+ <Show
341
+ when={!preview.loading}
342
+ fallback={
343
+ <p class="text-sm text-muted">Loading preview…</p>
344
+ }
345
+ >
346
+ <Show
347
+ when={matchExcerpts(preview() ?? "", query()).length}
348
+ fallback={
349
+ <p class="text-sm text-muted leading-relaxed whitespace-pre-wrap m-0">
350
+ {stripMarkdown(preview() ?? "").slice(0, 800) ||
351
+ "No preview available."}
352
+ </p>
353
+ }
354
+ >
355
+ <div class="flex flex-col gap-3">
356
+ <For each={matchExcerpts(preview() ?? "", query())}>
357
+ {(ex) => (
358
+ <p class="text-sm text-foreground/90 leading-relaxed m-0 p-3 rounded-lg bg-background/60 border border-border/60">
359
+ <Highlighted text={ex} query={query()} />
360
+ </p>
361
+ )}
362
+ </For>
363
+ </div>
364
+ </Show>
365
+ </Show>
366
+ </div>
367
+ )}
368
+ </Show>
369
+ </section>
370
+ </div>
371
+ </Show>
372
+ </div>
373
+ );
374
+ }
@@ -1,26 +1,59 @@
1
+ import { Link } from "@tanstack/solid-router";
1
2
  import type { JSX } from "solid-js";
2
3
  import { createMemo, createSignal, For, Show } from "solid-js";
3
4
  import type { ShowcaseInfo } from "../config";
4
5
  import { useDocs } from "../context";
5
6
 
6
- function Card(props: { link?: string; children: JSX.Element }) {
7
+ function Card(props: {
8
+ item: ShowcaseInfo;
9
+ onDetails?: () => void;
10
+ children: JSX.Element;
11
+ }) {
7
12
  const classes =
8
- "group border border-border rounded-xl overflow-hidden bg-surface/30 hover:border-focus transition-all hover:-translate-y-0.5 hover:shadow-lg flex flex-col";
9
- return props.link ? (
10
- <a
11
- href={props.link}
12
- target="_blank"
13
- rel="noreferrer"
14
- class={`${classes} no-underline`}
13
+ "group border border-border rounded-xl overflow-hidden bg-surface/30 hover:border-focus transition-all hover:-translate-y-0.5 hover:shadow-lg flex flex-col text-left";
14
+ const { item } = props;
15
+ if (item.collection && item.docId) {
16
+ return (
17
+ <Link
18
+ to="/$collection/$docId"
19
+ params={{ collection: item.collection, docId: item.docId }}
20
+ class={`${classes} no-underline`}
21
+ >
22
+ {props.children}
23
+ </Link>
24
+ );
25
+ }
26
+ if (item.link?.startsWith("/")) {
27
+ return (
28
+ <Link to={item.link} class={`${classes} no-underline`}>
29
+ {props.children}
30
+ </Link>
31
+ );
32
+ }
33
+ if (item.link) {
34
+ return (
35
+ <a
36
+ href={item.link}
37
+ target="_blank"
38
+ rel="noreferrer"
39
+ class={`${classes} no-underline`}
40
+ >
41
+ {props.children}
42
+ </a>
43
+ );
44
+ }
45
+ return (
46
+ <button
47
+ type="button"
48
+ onClick={props.onDetails}
49
+ class={`${classes} cursor-pointer bg-transparent p-0 font-inherit`}
15
50
  >
16
51
  {props.children}
17
- </a>
18
- ) : (
19
- <div class={classes}>{props.children}</div>
52
+ </button>
20
53
  );
21
54
  }
22
55
 
23
- function ShowcaseCard(props: { item: ShowcaseInfo }) {
56
+ function ShowcaseCard(props: { item: ShowcaseInfo; onDetails?: () => void }) {
24
57
  const { item } = props;
25
58
  const coverStyle = () => {
26
59
  if (item.image) return {};
@@ -31,7 +64,7 @@ function ShowcaseCard(props: { item: ShowcaseInfo }) {
31
64
  };
32
65
 
33
66
  return (
34
- <Card link={item.link}>
67
+ <Card item={item} onDetails={props.onDetails}>
35
68
  <div
36
69
  class="relative h-44 w-full bg-background border-b border-border overflow-hidden"
37
70
  style={coverStyle()}
@@ -94,6 +127,7 @@ export function ShowcasePage() {
94
127
  const allItems = () => config.showcase ?? [];
95
128
  const [search, setSearch] = createSignal("");
96
129
  const [tag, setTag] = createSignal<string | null>(null);
130
+ const [detailItem, setDetailItem] = createSignal<ShowcaseInfo | null>(null);
97
131
 
98
132
  const allTags = createMemo(() => {
99
133
  const set = new Set<string>();
@@ -182,7 +216,10 @@ export function ShowcasePage() {
182
216
  <section class="mb-10">
183
217
  <h2 class="text-lg font-semibold mb-3 text-muted">Featured</h2>
184
218
  <div class="sm:max-w-2xl">
185
- <ShowcaseCard item={featured()!} />
219
+ <ShowcaseCard
220
+ item={featured()!}
221
+ onDetails={() => setDetailItem(featured()!)}
222
+ />
186
223
  </div>
187
224
  </section>
188
225
  </Show>
@@ -195,8 +232,99 @@ export function ShowcasePage() {
195
232
  </Show>
196
233
 
197
234
  <div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
198
- <For each={rest()}>{(item) => <ShowcaseCard item={item} />}</For>
235
+ <For each={rest()}>
236
+ {(item) => (
237
+ <ShowcaseCard item={item} onDetails={() => setDetailItem(item)} />
238
+ )}
239
+ </For>
199
240
  </div>
241
+
242
+ <Show when={detailItem()}>
243
+ {(item) => (
244
+ <div
245
+ class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm"
246
+ onClick={() => setDetailItem(null)}
247
+ >
248
+ <div
249
+ role="dialog"
250
+ aria-modal="true"
251
+ aria-label={item().label}
252
+ class="w-full max-w-lg rounded-xl border border-border bg-surface shadow-2xl overflow-hidden"
253
+ onClick={(e) => e.stopPropagation()}
254
+ >
255
+ <div class="relative h-40 bg-background border-b border-border flex items-center justify-center">
256
+ <Show
257
+ when={item().image}
258
+ fallback={
259
+ <span
260
+ class={`${item().icon ?? "i-mdi:view-dashboard"} text-7xl text-primary/60`}
261
+ aria-hidden="true"
262
+ />
263
+ }
264
+ >
265
+ <img
266
+ src={item().image!}
267
+ alt={item().label}
268
+ class="w-full h-full object-cover"
269
+ />
270
+ </Show>
271
+ <button
272
+ type="button"
273
+ onClick={() => setDetailItem(null)}
274
+ aria-label="Close"
275
+ class="absolute top-3 right-3 w-8 h-8 inline-flex items-center justify-center rounded-md bg-surface/80 border border-border text-muted hover:text-foreground transition-colors cursor-pointer"
276
+ >
277
+ <span class="i-mdi:close" aria-hidden="true" />
278
+ </button>
279
+ </div>
280
+ <div class="p-6">
281
+ <div class="flex items-center gap-2 mb-2">
282
+ <h3 class="text-xl font-semibold m-0">{item().label}</h3>
283
+ <Show when={item().badge}>
284
+ <span class="text-[10px] px-2 py-0.5 rounded-full bg-primary/15 text-primary font-medium">
285
+ {item().badge}
286
+ </span>
287
+ </Show>
288
+ </div>
289
+ <p class="text-sm text-muted leading-relaxed m-0">
290
+ {item().description}
291
+ </p>
292
+ <Show when={item().tags?.length}>
293
+ <div class="flex flex-wrap gap-1.5 mt-4">
294
+ <For each={item().tags}>
295
+ {(t) => (
296
+ <span class="text-[10px] px-2 py-0.5 rounded-full bg-background border border-border text-muted">
297
+ {t}
298
+ </span>
299
+ )}
300
+ </For>
301
+ </div>
302
+ </Show>
303
+ <div class="flex items-center gap-2 mt-6">
304
+ <Show when={item().link}>
305
+ <a
306
+ href={item().link}
307
+ target="_blank"
308
+ rel="noreferrer"
309
+ class="px-4 h-9 inline-flex items-center gap-1.5 rounded-md bg-primary text-primary-foreground text-sm no-underline hover:bg-primary-hover transition-colors"
310
+ >
311
+ <span class="i-mdi:open-in-new" aria-hidden="true" />
312
+ Visit
313
+ </a>
314
+ </Show>
315
+ <button
316
+ type="button"
317
+ onClick={() => setDetailItem(null)}
318
+ class="px-4 h-9 inline-flex items-center rounded-md border border-border text-sm text-muted hover:text-foreground hover:bg-background transition-colors cursor-pointer"
319
+ >
320
+ Close
321
+ </button>
322
+ </div>
323
+ </div>
324
+ </div>
325
+ </div>
326
+ )}
327
+ </Show>
200
328
  </div>
201
329
  );
202
330
  }