@kirchdev/duxt 0.3.4 → 0.3.5-dev-main.43-3310f4b
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 +24 -9
- package/app/app.vue +17 -11
- package/app/assets/css/duxt.css +47 -6
- package/app/components/DuxtAnnouncements.vue +117 -0
- package/app/components/DuxtBrunoEntries.vue +62 -0
- package/app/components/DuxtCodeBlock.vue +18 -34
- package/app/components/DuxtCodeToolbar.vue +14 -0
- package/app/components/DuxtCopyButton.vue +33 -0
- package/app/components/DuxtCopyPage.vue +32 -30
- package/app/components/DuxtFooter.vue +39 -8
- package/app/components/DuxtHeader.vue +107 -48
- package/app/components/DuxtLandingProse.vue +1 -1
- package/app/components/DuxtLinkLabel.vue +36 -0
- package/app/components/DuxtLiveWindow.vue +8 -8
- package/app/components/DuxtLocale.vue +10 -1
- package/app/components/DuxtNavigation.vue +2 -2
- package/app/components/DuxtOpenApiClient.vue +285 -90
- package/app/components/DuxtOpenApiMedia.vue +30 -18
- package/app/components/DuxtOpenApiSchema.vue +10 -7
- package/app/components/DuxtPageFeedback.vue +7 -0
- package/app/components/DuxtPageInfo.vue +15 -12
- package/app/components/DuxtPageNav.vue +41 -27
- package/app/components/DuxtPreviewSkeleton.vue +4 -4
- package/app/components/DuxtProgress.vue +2 -14
- package/app/components/DuxtSearch.vue +230 -90
- package/app/components/DuxtSearchInput.vue +70 -0
- package/app/components/DuxtSections.vue +11 -1
- package/app/components/DuxtSegmented.vue +64 -0
- package/app/components/DuxtShortcuts.vue +16 -11
- package/app/components/DuxtShortcutsTrigger.vue +49 -0
- package/app/components/DuxtSkipLink.vue +1 -1
- package/app/components/DuxtToc.vue +16 -10
- package/app/components/DuxtTranslationBanner.vue +1 -1
- package/app/components/DuxtVersion.vue +53 -12
- package/app/components/DuxtVersionBanner.vue +5 -5
- package/app/components/content/AccordionItem.vue +2 -2
- package/app/components/content/BrunoOverview.vue +260 -0
- package/app/components/content/BrunoRequest.vue +141 -0
- package/app/components/content/BrunoRequests.vue +104 -0
- package/app/components/content/Callout.vue +5 -5
- package/app/components/content/ChangelogReleases.vue +161 -40
- package/app/components/content/CodeGroup.vue +41 -37
- package/app/components/content/Field.vue +1 -1
- package/app/components/content/FileTree.vue +1 -1
- package/app/components/content/OpenApiOperation.vue +4 -4
- package/app/components/content/OpenApiOperations.vue +1 -1
- package/app/components/content/OpenApiOverview.vue +1 -1
- package/app/components/content/PackageManagers.vue +42 -58
- package/app/components/content/PageCards.vue +7 -4
- package/app/components/content/Preview.vue +15 -39
- package/app/components/content/ProseImg.vue +596 -49
- package/app/components/content/Since.vue +1 -1
- package/app/components/content/Steps.vue +6 -4
- package/app/components/content/TabGroup.vue +13 -12
- package/app/components/ui/breadcrumb/BreadcrumbSeparator.vue +1 -1
- package/app/components/ui/command/CommandShortcut.vue +1 -1
- package/app/components/ui/dialog/DialogContent.vue +2 -2
- package/app/components/ui/dialog/DialogHeader.vue +1 -1
- package/app/components/ui/dialog/DialogScrollContent.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +2 -2
- package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +2 -2
- package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +2 -2
- package/app/components/ui/navigation-menu/NavigationMenuContent.vue +1 -1
- package/app/components/ui/navigation-menu/NavigationMenuTrigger.vue +1 -1
- package/app/components/ui/navigation-menu/NavigationMenuViewport.vue +1 -1
- package/app/components/ui/scroll-area/ScrollBar.vue +1 -1
- package/app/components/ui/select/SelectItem.vue +2 -2
- package/app/components/ui/sheet/SheetContent.vue +29 -3
- package/app/components/ui/sidebar/Sidebar.vue +28 -4
- package/app/components/ui/sidebar/SidebarGroupAction.vue +1 -1
- package/app/components/ui/sidebar/SidebarInset.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuAction.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuSub.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
- package/app/components/ui/sidebar/SidebarProvider.vue +7 -16
- package/app/components/ui/sidebar/index.ts +10 -2
- package/app/components/ui/sidebar/utils.ts +0 -1
- package/app/components/ui/sonner/Sonner.vue +18 -0
- package/app/components/ui/tabs/TabsList.vue +13 -3
- package/app/components/ui/tabs/TabsTrigger.vue +24 -3
- package/app/components/ui/tooltip/TooltipContent.vue +2 -2
- package/app/composables/useActiveHeading.ts +3 -25
- package/app/composables/useDuxtAnalytics.ts +50 -0
- package/app/composables/useDuxtAnimatedHeight.ts +8 -38
- package/app/composables/useDuxtAnnouncements.ts +153 -0
- package/app/composables/useDuxtCopy.ts +51 -0
- package/app/composables/useDuxtDirection.ts +23 -0
- package/app/composables/useDuxtNavigation.ts +28 -8
- package/app/composables/useDuxtPath.ts +3 -5
- package/app/composables/useDuxtSearch.ts +87 -35
- package/app/composables/useDuxtSection.ts +15 -1
- package/app/composables/useDuxtSectionRow.ts +6 -5
- package/app/composables/useDuxtShortcuts.ts +319 -20
- package/app/composables/useDuxtVersion.ts +57 -6
- package/app/composables/useDuxtViewportMeasure.ts +37 -0
- package/app/composables/useFuzzySearch.ts +9 -4
- package/app/composables/useRecentPages.ts +14 -17
- package/app/error.vue +112 -74
- package/app/layouts/changelog.vue +9 -1
- package/app/layouts/default.vue +9 -0
- package/app/layouts/docs.vue +9 -1
- package/app/layouts/reference.vue +9 -1
- package/app/middleware/duxt-section-layout.global.ts +1 -3
- package/app/pages/[...slug].vue +146 -35
- package/app/pages/index.vue +15 -23
- package/app/types/duxt.d.ts +312 -4
- package/app/utils/analytics.ts +91 -0
- package/app/utils/announcements.ts +205 -0
- package/app/utils/changelog.ts +0 -28
- package/app/utils/code-highlight.ts +3 -1
- package/app/utils/contributor-avatar.ts +27 -0
- package/app/utils/direction.ts +43 -0
- package/app/utils/duxt-config.ts +43 -0
- package/app/utils/duxt-text.ts +10 -0
- package/app/utils/format-date.ts +28 -0
- package/app/utils/generated-sections.ts +68 -12
- package/app/utils/generated-toc.ts +14 -7
- package/app/utils/locale-path.ts +43 -0
- package/app/utils/navigation-tree.ts +51 -1
- package/app/utils/nearest-page.ts +8 -0
- package/app/utils/openapi.ts +16 -11
- package/app/utils/page-controls.ts +215 -0
- package/app/utils/pill.ts +61 -0
- package/app/utils/prose-image.ts +128 -0
- package/app/utils/search-context.ts +110 -0
- package/app/utils/search-display.ts +248 -0
- package/app/utils/section-scope.ts +65 -4
- package/app/utils/stored-json.ts +31 -0
- package/app/utils/version-choices.ts +6 -13
- package/app/utils/version-paths.ts +21 -0
- package/bin/duxt-cache-key.mjs +61 -0
- package/bin/duxt-og-cache.mjs +58 -0
- package/bin/{duxt-report.mjs → duxt.mjs} +13 -7
- package/bruno-model.ts +169 -0
- package/bruno-parse.ts +656 -0
- package/bruno-zip.ts +195 -0
- package/cli.ts +240 -0
- package/duxt-app-config.ts +18 -1
- package/frontmatter.ts +49 -0
- package/git-contributors.ts +322 -0
- package/i18n/locales/de/duxt/announcement.json +7 -0
- package/i18n/locales/de/duxt/bruno.json +25 -0
- package/i18n/locales/de/duxt/changelog.json +1 -1
- package/i18n/locales/de/duxt/code.json +2 -1
- package/i18n/locales/de/duxt/page.json +9 -0
- package/i18n/locales/de/duxt/shortcuts.json +1 -0
- package/i18n/locales/de/duxt/theme.json +2 -1
- package/i18n/locales/en/duxt/announcement.json +7 -0
- package/i18n/locales/en/duxt/bruno.json +25 -0
- package/i18n/locales/en/duxt/changelog.json +1 -1
- package/i18n/locales/en/duxt/code.json +2 -1
- package/i18n/locales/en/duxt/page.json +9 -0
- package/i18n/locales/en/duxt/shortcuts.json +1 -0
- package/i18n/locales/en/duxt/theme.json +2 -1
- package/i18n/locales/es/duxt/announcement.json +7 -0
- package/i18n/locales/es/duxt/bruno.json +25 -0
- package/i18n/locales/es/duxt/changelog.json +1 -1
- package/i18n/locales/es/duxt/code.json +2 -1
- package/i18n/locales/es/duxt/page.json +9 -0
- package/i18n/locales/es/duxt/shortcuts.json +1 -0
- package/i18n/locales/es/duxt/theme.json +2 -1
- package/i18n/locales/fr/duxt/announcement.json +7 -0
- package/i18n/locales/fr/duxt/bruno.json +25 -0
- package/i18n/locales/fr/duxt/changelog.json +1 -1
- package/i18n/locales/fr/duxt/code.json +2 -1
- package/i18n/locales/fr/duxt/page.json +9 -0
- package/i18n/locales/fr/duxt/shortcuts.json +1 -0
- package/i18n/locales/fr/duxt/theme.json +2 -1
- package/i18n/locales/pt/duxt/announcement.json +7 -0
- package/i18n/locales/pt/duxt/bruno.json +25 -0
- package/i18n/locales/pt/duxt/changelog.json +1 -1
- package/i18n/locales/pt/duxt/code.json +2 -1
- package/i18n/locales/pt/duxt/page.json +9 -0
- package/i18n/locales/pt/duxt/shortcuts.json +1 -0
- package/i18n/locales/pt/duxt/theme.json +2 -1
- package/modules/bruno.ts +118 -0
- package/modules/config.ts +26 -0
- package/modules/devtools.ts +2 -3
- package/modules/git-meta.ts +16 -47
- package/modules/search-records.ts +132 -0
- package/nuxt.config.ts +81 -7
- package/og-image-cache.ts +742 -0
- package/package.json +41 -9
- package/public/devtools/checks.html +2 -2
- package/public/devtools/config.html +59 -4
- package/public/devtools/pages.html +8 -1
- package/public/devtools/paths.html +1 -1
- package/public/devtools/sources.html +2 -2
- package/public/devtools/versions.html +3 -0
- package/report.ts +28 -19
- package/search-records.ts +380 -0
- package/section-input.ts +142 -0
- package/section-markdown.ts +67 -0
- package/section-reports.ts +10 -4
- package/sections-bruno.ts +571 -0
- package/sections-changelog.ts +76 -43
- package/sections-openapi.ts +12 -83
- package/sections-resolve.ts +174 -8
- package/sections.ts +33 -10
- package/server/devtools/shell.ts +1 -1
- package/server/mcp/tools/list-pages.ts +77 -36
- package/server/mcp/tools/list-versions.ts +84 -0
- package/server/mcp/tools/read-page.ts +80 -26
- package/server/mcp/tools/search-docs.ts +87 -53
- package/server/middleware/raw-markdown.ts +1 -1
- package/server/routes/llms-full.txt.get.ts +6 -8
- package/server/routes/llms.txt.get.ts +1 -1
- package/server/utils/duxt-server-text.ts +0 -13
- package/server/utils/llms-pages.ts +24 -9
- package/server/utils/mcp-docs.ts +577 -0
- package/sources-cache.ts +376 -0
- package/sources-git.ts +186 -31
- package/sources-resolve.ts +59 -1
- package/sources.ts +122 -13
- package/tfplugindocs.ts +158 -0
|
@@ -9,8 +9,8 @@ import type { DuxtSearchHit } from '@duxt/composables/useDuxtSearch';
|
|
|
9
9
|
// Full-text search over EVERY source, not just the one being read. Content
|
|
10
10
|
// builds the index at build time and queries it with SQLite's FTS, so the
|
|
11
11
|
// ranking is the database's rather than a substring match of ours — and there
|
|
12
|
-
// is no search service to run. The merge across sources, and why
|
|
13
|
-
// are
|
|
12
|
+
// is no search service to run. The merge across sources, and why their locally
|
|
13
|
+
// ranked lists are interleaved rather than compared by score, is in
|
|
14
14
|
// `useDuxtSearch`.
|
|
15
15
|
//
|
|
16
16
|
// The index is fetched on first open, not shipped with every page: a docs site
|
|
@@ -21,9 +21,19 @@ const router = useRouter();
|
|
|
21
21
|
const localeLink = useDuxtLink();
|
|
22
22
|
|
|
23
23
|
const { search, init, labelled } = useDuxtSearch();
|
|
24
|
+
const analytics = useDuxtAnalytics();
|
|
24
25
|
|
|
25
26
|
const results = ref<DuxtSearchHit[]>([]);
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The term the current results were produced for.
|
|
30
|
+
*
|
|
31
|
+
* Not `query`, which moves with every keystroke: the excerpts window themselves
|
|
32
|
+
* around a literal match, and windowing them against a term newer than the
|
|
33
|
+
* results already on screen makes the previews jump while the reader types.
|
|
34
|
+
*/
|
|
35
|
+
const matched = ref('');
|
|
36
|
+
|
|
27
37
|
/** True while the list shows near-misses rather than actual matches. */
|
|
28
38
|
const approximate = ref(false);
|
|
29
39
|
|
|
@@ -42,54 +52,142 @@ watch(query, (term) => {
|
|
|
42
52
|
const current = ++run;
|
|
43
53
|
|
|
44
54
|
const settle = (hits: DuxtSearchHit[], fuzzy: boolean) => {
|
|
45
|
-
if (current !== run) return;
|
|
55
|
+
if (current !== run) return false;
|
|
46
56
|
results.value = hits;
|
|
47
57
|
approximate.value = fuzzy;
|
|
58
|
+
matched.value = term;
|
|
59
|
+
return true;
|
|
48
60
|
};
|
|
49
61
|
|
|
50
|
-
if (!term.trim()) return settle([], false);
|
|
62
|
+
if (!term.trim()) return void settle([], false);
|
|
51
63
|
|
|
52
64
|
const found = await search(term, 20);
|
|
53
|
-
|
|
65
|
+
|
|
66
|
+
// ONE EVENT PER SETTLED SEARCH, which is why it hangs off `settle` rather
|
|
67
|
+
// than off the call. The debounce already dropped the keystrokes nobody
|
|
68
|
+
// finished, and a run whose answer landed after a newer one shows the
|
|
69
|
+
// reader nothing — reporting it would count a search that never happened.
|
|
70
|
+
// A search that found nothing is reported like any other: "no result" is
|
|
71
|
+
// the finding a documentation site most needs out of this.
|
|
72
|
+
if (settle(found.hits, found.approximate)) {
|
|
73
|
+
analytics.track({
|
|
74
|
+
name: 'search',
|
|
75
|
+
query: term,
|
|
76
|
+
results: found.hits.length,
|
|
77
|
+
approximate: found.approximate
|
|
78
|
+
});
|
|
79
|
+
}
|
|
54
80
|
}, 120);
|
|
55
81
|
});
|
|
56
82
|
|
|
57
83
|
const duxt = useDuxtConfig();
|
|
58
|
-
const { recent, load } = useRecentPages();
|
|
84
|
+
const { recent, load } = useRecentPages(duxt.search?.recentPages);
|
|
85
|
+
|
|
86
|
+
const sections = computed(() => duxt.sections ?? []);
|
|
87
|
+
const sources = computed(() => duxt.resolvedSources ?? []);
|
|
59
88
|
|
|
60
89
|
/** Which section a path belongs to, for grouping the hits. */
|
|
61
90
|
function sectionOf(path: string) {
|
|
62
91
|
return (
|
|
63
|
-
|
|
64
|
-
?.label ?? 'Documentation'
|
|
92
|
+
sectionLabelForPath(path, sections.value, sources.value) ?? 'Documentation'
|
|
65
93
|
);
|
|
66
94
|
}
|
|
67
95
|
|
|
96
|
+
/**
|
|
97
|
+
* What each source is CALLED, rather than which URL segment it occupies.
|
|
98
|
+
*
|
|
99
|
+
* The context line below used to spell its source slot out of the manifest's
|
|
100
|
+
* `repo`, which is an address: documentation served at the root had none and
|
|
101
|
+
* showed nothing, and a source whose segment is an abbreviation showed the
|
|
102
|
+
* abbreviation. See `sourceDisplayNames` for the ladder, and `sources[].name`
|
|
103
|
+
* for the one rung a consumer writes.
|
|
104
|
+
*/
|
|
105
|
+
const names = computed(() =>
|
|
106
|
+
sourceDisplayNames(sources.value, asText(duxt.title) ?? '')
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The line under every row's title, and deliberately the SAME line in both
|
|
111
|
+
* lists.
|
|
112
|
+
*
|
|
113
|
+
* "Recently viewed" showed a title and its section, which on a site serving
|
|
114
|
+
* several editions of one repository is three identical rows — the reader saw
|
|
115
|
+
* `Harbour` three times and had to open them to find out which was which. A hit
|
|
116
|
+
* had the opposite problem: it named the page it sat in but not the edition
|
|
117
|
+
* that page came from. One `section · source/version · route` answers both, and
|
|
118
|
+
* answering them the same way is what makes the palette scannable.
|
|
119
|
+
*/
|
|
120
|
+
const contextOf = (path: string) =>
|
|
121
|
+
searchContext(path, sections.value, sources.value, { names: names.value });
|
|
122
|
+
|
|
123
|
+
const history = computed(() =>
|
|
124
|
+
recent.value.map((page) => ({ ...page, context: contextOf(page.path) }))
|
|
125
|
+
);
|
|
126
|
+
|
|
68
127
|
/**
|
|
69
128
|
* Hits grouped by section rather than by page. Grouping by page produced one
|
|
70
129
|
* heading per result; a section groups many, which is what a heading is for —
|
|
71
130
|
* and the reader already thinks in sections, because the navbar shows them.
|
|
72
131
|
*/
|
|
73
132
|
const grouped = computed(() => {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
133
|
+
const rows = results.value.map((hit) => ({
|
|
134
|
+
...hit,
|
|
135
|
+
// WITHOUT the source slot wherever a caption above the run already states
|
|
136
|
+
// it — see `searchRows`. The section and the route stay, because those are
|
|
137
|
+
// the row's own and are what tell two editions of one title apart.
|
|
138
|
+
context: searchContext(hit.id, sections.value, sources.value, {
|
|
139
|
+
names: names.value,
|
|
140
|
+
source: !labelled.value
|
|
141
|
+
}),
|
|
142
|
+
// A title and a route say where a result IS; they do not say whether it
|
|
143
|
+
// answers the question, which is the judgement made before opening a page.
|
|
144
|
+
excerpt: searchExcerpt(hit.content, matched.value)
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
// With several sources the list is ONE ranked list whose rows say where they
|
|
148
|
+
// came from — grouping it by section would re-sort exactly the ranking the
|
|
149
|
+
// merge just produced. See `useDuxtSearch` for why ranking beats grouping.
|
|
77
150
|
if (labelled.value) {
|
|
78
|
-
return [{ label: '', hits:
|
|
151
|
+
return [{ label: '', hits: rows }];
|
|
79
152
|
}
|
|
80
153
|
|
|
81
|
-
const bySection = new Map<string,
|
|
154
|
+
const bySection = new Map<string, typeof rows>();
|
|
82
155
|
|
|
83
|
-
for (const hit of
|
|
84
|
-
|
|
85
|
-
// plain string, and by this point useDuxtConfig has resolved it.
|
|
86
|
-
const label = asText(sectionOf(hit.id)) ?? 'Documentation';
|
|
156
|
+
for (const hit of rows) {
|
|
157
|
+
const label = sectionOf(hit.id);
|
|
87
158
|
bySection.set(label, [...(bySection.get(label) ?? []), hit]);
|
|
88
159
|
}
|
|
89
160
|
|
|
90
161
|
return [...bySection.entries()].map(([label, hits]) => ({ label, hits }));
|
|
91
162
|
});
|
|
92
163
|
|
|
164
|
+
/**
|
|
165
|
+
* The groups above, with each one's repeated source label lifted into captions.
|
|
166
|
+
*
|
|
167
|
+
* Per GROUP rather than over the flat list: a caption opens a run, and a run
|
|
168
|
+
* reaching across a section heading would be opened by a caption sitting under
|
|
169
|
+
* the wrong heading. `searchRows` keeps the order and the count of what it is
|
|
170
|
+
* given either way — see there for why provenance must never re-sort.
|
|
171
|
+
*/
|
|
172
|
+
const groups = computed(() =>
|
|
173
|
+
grouped.value.map((group) => ({
|
|
174
|
+
label: group.label,
|
|
175
|
+
rows: searchRows(group.hits)
|
|
176
|
+
}))
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const filter = useTemplateRef<{ refresh: () => void }>('filter');
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Hand the highlight back to the input's list every time the rows change.
|
|
183
|
+
*
|
|
184
|
+
* `flush: 'post'`, because what the input re-seats the highlight against is the
|
|
185
|
+
* DOM: asked before Vue has patched it, reka would measure the rows this
|
|
186
|
+
* answer just replaced. Why the input owns that decision rather than this
|
|
187
|
+
* component, and what it does with it, is in `DuxtSearchInput.refresh`.
|
|
188
|
+
*/
|
|
189
|
+
watch(results, () => filter.value?.refresh(), { flush: 'post' });
|
|
190
|
+
|
|
93
191
|
async function show() {
|
|
94
192
|
load();
|
|
95
193
|
open.value = true;
|
|
@@ -104,30 +202,45 @@ function go(id: string) {
|
|
|
104
202
|
router.push(localeLink(id)!);
|
|
105
203
|
}
|
|
106
204
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
205
|
+
/**
|
|
206
|
+
* A hit the reader opened — not a section tile and not a recent page, which
|
|
207
|
+
* `go` also serves and which nobody searched for.
|
|
208
|
+
*
|
|
209
|
+
* The rank is the position in the MERGED list rather than in the group it is
|
|
210
|
+
* drawn in: the list is one ranking with headings and captions over it, so a
|
|
211
|
+
* hit's place in its section says nothing about how well the search did. Found
|
|
212
|
+
* by `id` rather than by identity, because a row is a COPY of its hit — it
|
|
213
|
+
* carries the context line and excerpt spread onto it — so the object the
|
|
214
|
+
* template hands back is never the one sitting in `results`. Reported before
|
|
215
|
+
* `go`, which clears the term this hit was found with.
|
|
216
|
+
*/
|
|
217
|
+
function openResult(hit: DuxtSearchHit) {
|
|
218
|
+
analytics.track({
|
|
219
|
+
name: 'search-result',
|
|
220
|
+
query: query.value,
|
|
221
|
+
rank: results.value.findIndex((entry) => entry.id === hit.id) + 1,
|
|
222
|
+
// The path, never the hit's rendered text.
|
|
223
|
+
to: hit.id,
|
|
224
|
+
collection: hit.source?.collection
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
go(hit.id);
|
|
113
228
|
}
|
|
114
229
|
|
|
115
|
-
|
|
116
|
-
const handler = (event: KeyboardEvent) => {
|
|
117
|
-
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
|
118
|
-
event.preventDefault();
|
|
119
|
-
|
|
120
|
-
if (open.value) {
|
|
121
|
-
open.value = false;
|
|
122
|
-
} else {
|
|
123
|
-
void show();
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
};
|
|
230
|
+
const { hint, on } = useDuxtShortcuts();
|
|
127
231
|
|
|
128
|
-
|
|
129
|
-
|
|
232
|
+
on('search', () => {
|
|
233
|
+
if (open.value) open.value = false;
|
|
234
|
+
else void show();
|
|
130
235
|
});
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The hint on the button, from the same definition the handler binds.
|
|
239
|
+
*
|
|
240
|
+
* Through the LIVE list rather than the raw table, so a site that has unbound
|
|
241
|
+
* the key draws no cap at all instead of advertising a chord nothing answers.
|
|
242
|
+
*/
|
|
243
|
+
const searchHint = computed(() => hint('search'));
|
|
131
244
|
</script>
|
|
132
245
|
|
|
133
246
|
<template>
|
|
@@ -141,9 +254,10 @@ onMounted(() => {
|
|
|
141
254
|
<Icon name="lucide:search" class="size-4" />
|
|
142
255
|
<span class="text-sm">{{ $t('duxt.search.label') }}</span>
|
|
143
256
|
<kbd
|
|
144
|
-
|
|
257
|
+
v-if="searchHint"
|
|
258
|
+
class="ms-auto hidden rounded border bg-muted px-1.5 font-mono text-[10px] sm:inline-block"
|
|
145
259
|
>
|
|
146
|
-
|
|
260
|
+
{{ searchHint }}
|
|
147
261
|
</kbd>
|
|
148
262
|
</UiButton>
|
|
149
263
|
|
|
@@ -153,22 +267,9 @@ onMounted(() => {
|
|
|
153
267
|
against rendered text — dropping entries that mounted after the term
|
|
154
268
|
changed. Leaving its state empty keeps one filter in charge, the
|
|
155
269
|
database's. It also means CommandEmpty never renders, so the empty
|
|
156
|
-
state is ours too.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
them in the same corner is one too many. -->
|
|
160
|
-
<div class="flex items-center gap-2 border-b px-3">
|
|
161
|
-
<Icon
|
|
162
|
-
name="lucide:search"
|
|
163
|
-
class="size-4 shrink-0 text-muted-foreground"
|
|
164
|
-
/>
|
|
165
|
-
<input
|
|
166
|
-
v-model="query"
|
|
167
|
-
class="flex h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
168
|
-
:placeholder="$t('duxt.search.placeholder')"
|
|
169
|
-
autofocus
|
|
170
|
-
/>
|
|
171
|
-
</div>
|
|
270
|
+
state is ours too. What it is INSTEAD, and why that is a component
|
|
271
|
+
rather than an `<input>`, is in `DuxtSearchInput`. -->
|
|
272
|
+
<DuxtSearchInput ref="filter" v-model="query" />
|
|
172
273
|
|
|
173
274
|
<UiCommandList class="max-h-[60vh]">
|
|
174
275
|
<!-- An empty box is a dead end. Without a term the dialog offers the
|
|
@@ -180,19 +281,24 @@ onMounted(() => {
|
|
|
180
281
|
:heading="$t('duxt.search.recent')"
|
|
181
282
|
>
|
|
182
283
|
<UiCommandItem
|
|
183
|
-
v-for="page in
|
|
284
|
+
v-for="page in history"
|
|
184
285
|
:key="page.path"
|
|
185
286
|
:value="`recent ${page.path}`"
|
|
186
|
-
class="gap-2"
|
|
287
|
+
class="items-start gap-2"
|
|
187
288
|
@select="go(page.path)"
|
|
188
289
|
>
|
|
189
290
|
<Icon
|
|
190
291
|
name="lucide:history"
|
|
191
|
-
class="size-3.5 shrink-0 text-muted-foreground"
|
|
292
|
+
class="mt-0.5 size-3.5 shrink-0 text-muted-foreground"
|
|
192
293
|
/>
|
|
193
|
-
<span class="
|
|
194
|
-
|
|
195
|
-
|
|
294
|
+
<span class="flex min-w-0 flex-col">
|
|
295
|
+
<span class="truncate">{{ page.title }}</span>
|
|
296
|
+
<span
|
|
297
|
+
v-if="page.context"
|
|
298
|
+
class="truncate text-xs text-muted-foreground"
|
|
299
|
+
>
|
|
300
|
+
{{ page.context }}
|
|
301
|
+
</span>
|
|
196
302
|
</span>
|
|
197
303
|
</UiCommandItem>
|
|
198
304
|
</UiCommandGroup>
|
|
@@ -224,47 +330,81 @@ onMounted(() => {
|
|
|
224
330
|
|
|
225
331
|
<!-- Say so when the exact search came up empty, otherwise a near-miss
|
|
226
332
|
reads as a match and the reader wonders why their term is missing
|
|
227
|
-
from the result.
|
|
333
|
+
from the result.
|
|
334
|
+
|
|
335
|
+
ONLY WITH SOMETHING TO SHOW. The approximate pass can come up empty
|
|
336
|
+
too, and then this line stood under "Nothing found" promising the
|
|
337
|
+
closest matches of a list that had none. -->
|
|
228
338
|
<div
|
|
229
|
-
v-if="approximate"
|
|
339
|
+
v-if="approximate && results.length"
|
|
230
340
|
class="flex items-center gap-2 px-3 pt-3 pb-1 text-xs text-muted-foreground"
|
|
231
341
|
>
|
|
232
342
|
<Icon name="lucide:sparkles" class="size-3.5 shrink-0" />
|
|
233
343
|
{{ $t('duxt.search.approximate', { query }) }}
|
|
234
344
|
</div>
|
|
235
345
|
|
|
236
|
-
<!--
|
|
237
|
-
|
|
346
|
+
<!-- THREE LINES per hit: the title, the same context line the history
|
|
347
|
+
above carries, and an excerpt of the section's own text. The
|
|
348
|
+
repository badge is gone — provenance is stated once at the head of
|
|
349
|
+
each run of results that share it, rather than printed onto every
|
|
350
|
+
row, which was the repetition both halves of this dialog were asked
|
|
351
|
+
to remove. -->
|
|
238
352
|
<UiCommandGroup
|
|
239
|
-
v-for="group in
|
|
353
|
+
v-for="group in groups"
|
|
240
354
|
:key="group.label"
|
|
241
355
|
:heading="group.label || undefined"
|
|
242
356
|
>
|
|
243
|
-
<
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
class="
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
v-if="hit.source"
|
|
262
|
-
variant="secondary"
|
|
263
|
-
class="shrink-0 font-mono text-[10px]"
|
|
357
|
+
<template v-for="row in group.rows" :key="row.hit.id">
|
|
358
|
+
<!-- Opens a run of hits from one source. `aria-hidden`, because the
|
|
359
|
+
same text reaches a screen reader on every row below through
|
|
360
|
+
`row.provenance` — a caption is a way of not repeating something
|
|
361
|
+
visually, and repeating it to one reader while hiding it from
|
|
362
|
+
another is how a shared label becomes a lie. -->
|
|
363
|
+
<div
|
|
364
|
+
v-if="row.caption"
|
|
365
|
+
aria-hidden="true"
|
|
366
|
+
class="truncate px-2 pt-3 pb-1 text-[11px] font-medium text-muted-foreground/80 first:pt-1"
|
|
367
|
+
>
|
|
368
|
+
{{ row.caption }}
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
<UiCommandItem
|
|
372
|
+
:value="row.hit.id"
|
|
373
|
+
class="items-start gap-2"
|
|
374
|
+
@select="openResult(row.hit)"
|
|
264
375
|
>
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
376
|
+
<Icon
|
|
377
|
+
:name="row.hit.level > 1 ? 'lucide:hash' : 'lucide:file-text'"
|
|
378
|
+
class="mt-0.5 size-3.5 shrink-0 text-muted-foreground"
|
|
379
|
+
/>
|
|
380
|
+
<!-- `min-w-0` is what actually lets the lines truncate: a flex item
|
|
381
|
+
defaults to `min-width: auto`, which refuses to shrink below
|
|
382
|
+
its content and pushes the dialog into a horizontal scroll on a
|
|
383
|
+
320px screen instead. -->
|
|
384
|
+
<span class="flex min-w-0 flex-col">
|
|
385
|
+
<span class="truncate">{{ row.hit.title }}</span>
|
|
386
|
+
<span
|
|
387
|
+
v-if="row.hit.context"
|
|
388
|
+
class="truncate text-xs text-muted-foreground"
|
|
389
|
+
>
|
|
390
|
+
{{ row.hit.context }}
|
|
391
|
+
</span>
|
|
392
|
+
<!-- Interpolated, never `v-html`: this is a document's own text,
|
|
393
|
+
and a page that writes about `<script>` must read as prose
|
|
394
|
+
rather than run as markup. Dropped entirely when the section
|
|
395
|
+
has no text, so an empty preview leaves no empty row. -->
|
|
396
|
+
<span
|
|
397
|
+
v-if="row.hit.excerpt"
|
|
398
|
+
class="line-clamp-2 text-xs text-muted-foreground/80"
|
|
399
|
+
>
|
|
400
|
+
{{ row.hit.excerpt }}
|
|
401
|
+
</span>
|
|
402
|
+
<span v-if="row.provenance" class="sr-only">
|
|
403
|
+
{{ row.provenance }}
|
|
404
|
+
</span>
|
|
405
|
+
</span>
|
|
406
|
+
</UiCommandItem>
|
|
407
|
+
</template>
|
|
268
408
|
</UiCommandGroup>
|
|
269
409
|
</UiCommandList>
|
|
270
410
|
</UiCommandDialog>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The search dialog's input — reka's `ListboxFilter`, and NOT shadcn's
|
|
3
|
+
// `CommandInput`.
|
|
4
|
+
//
|
|
5
|
+
// `CommandInput` is `ListboxFilter` plus one line: it writes the term into
|
|
6
|
+
// Command's own `filterState`, and Command then scores the rendered list a
|
|
7
|
+
// SECOND time against its text — dropping entries that mounted after the term
|
|
8
|
+
// changed, and re-ranking what the database already ranked. So the term must
|
|
9
|
+
// never reach `filterState`, and the conclusion long drawn from that was to
|
|
10
|
+
// drop `ListboxFilter` along with it and type into a plain `<input>`.
|
|
11
|
+
//
|
|
12
|
+
// That threw away the half that was never the problem. `ListboxFilter` filters
|
|
13
|
+
// NOTHING. What it does is the combobox wiring: forward Arrow/Home/End to the
|
|
14
|
+
// listbox and Enter to the highlighted item, publish `aria-activedescendant`,
|
|
15
|
+
// and clear `focusable` so the list leaves the tab order and the input keeps
|
|
16
|
+
// focus while the list keeps the highlight. Without it the results were
|
|
17
|
+
// reachable by mouse only — #89.
|
|
18
|
+
//
|
|
19
|
+
// It is a component of its own because `highlightFirstItem` is reka's API for a
|
|
20
|
+
// DESCENDANT of the listbox root, and `DuxtSearch` is its PARENT: it renders
|
|
21
|
+
// the root, so it cannot reach in. One component inside the dialog can, and
|
|
22
|
+
// `refresh()` is what it hands back.
|
|
23
|
+
//
|
|
24
|
+
// No clear button: `DialogContent` already draws a close X, and two of them in
|
|
25
|
+
// the same corner is one too many.
|
|
26
|
+
import { ListboxFilter, injectListboxRootContext } from 'reka-ui';
|
|
27
|
+
|
|
28
|
+
const query = defineModel<string>({ default: '' });
|
|
29
|
+
|
|
30
|
+
const root = injectListboxRootContext();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Re-seat the highlight on the first row once a search has settled.
|
|
34
|
+
*
|
|
35
|
+
* `ListboxFilter` highlights the first item on every KEYSTROKE, which is right
|
|
36
|
+
* for a list filtered synchronously and wrong for one that is fetched: the
|
|
37
|
+
* database answers a debounce later, and by then the row it highlighted has
|
|
38
|
+
* unmounted. reka keeps the reference either way, so what is left is an
|
|
39
|
+
* `aria-activedescendant` naming an element no longer in the document and an
|
|
40
|
+
* Enter that does nothing — reka guards that one with `isConnected`. Those are
|
|
41
|
+
* #89's two symptoms again, one step further along.
|
|
42
|
+
*
|
|
43
|
+
* A highlight still on a live row is left exactly where it is: the reader put
|
|
44
|
+
* it there with the arrow keys, and a search that settles without changing the
|
|
45
|
+
* rows must not move it.
|
|
46
|
+
*/
|
|
47
|
+
function refresh() {
|
|
48
|
+
if (root.highlightedElement.value?.isConnected) return;
|
|
49
|
+
|
|
50
|
+
// Cleared FIRST, so a search that found nothing ends with no active
|
|
51
|
+
// descendant rather than a dangling one: `highlightFirstItem` only ever
|
|
52
|
+
// assigns, and over an empty collection it does nothing at all.
|
|
53
|
+
root.highlightedElement.value = null;
|
|
54
|
+
root.highlightFirstItem();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
defineExpose({ refresh });
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<template>
|
|
61
|
+
<div class="flex items-center gap-2 border-b px-3">
|
|
62
|
+
<Icon name="lucide:search" class="size-4 shrink-0 text-muted-foreground" />
|
|
63
|
+
<ListboxFilter
|
|
64
|
+
v-model="query"
|
|
65
|
+
auto-focus
|
|
66
|
+
class="flex h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
67
|
+
:placeholder="$t('duxt.search.placeholder')"
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
</template>
|
|
@@ -58,7 +58,17 @@ function current(to?: string) {
|
|
|
58
58
|
"
|
|
59
59
|
>
|
|
60
60
|
<Icon v-if="section.icon" :name="section.icon" class="size-4" />
|
|
61
|
-
|
|
61
|
+
<!-- The lit chip is `font-medium`, which is wider than the regular
|
|
62
|
+
weight, so every chip after it shifted sideways on each navigation.
|
|
63
|
+
The invisible zero-height `::after` carries the label at the medium
|
|
64
|
+
weight and reserves that width whether the chip is lit or not. A
|
|
65
|
+
pseudo-element rather than a second span, so `textContent` stays the
|
|
66
|
+
label once and `visibility: hidden` keeps it out of the name. -->
|
|
67
|
+
<span
|
|
68
|
+
class="inline-flex flex-col after:invisible after:h-0 after:overflow-hidden after:font-medium after:content-[attr(data-label)]"
|
|
69
|
+
:data-label="section.label"
|
|
70
|
+
>{{ section.label }}</span
|
|
71
|
+
>
|
|
62
72
|
</NuxtLink>
|
|
63
73
|
</nav>
|
|
64
74
|
</div>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T extends string">
|
|
2
|
+
import type { HTMLAttributes } from 'vue';
|
|
3
|
+
import { cn } from '@duxt/lib/utils';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A row of pressed-or-not buttons: pick one of a few, see which is picked.
|
|
7
|
+
*
|
|
8
|
+
* `role="group"` with `aria-pressed`, NOT a radiogroup or a tablist: a
|
|
9
|
+
* radiogroup promises arrow-key navigation between its options and a tablist
|
|
10
|
+
* promises panels, and this implements neither. A group of pressed buttons
|
|
11
|
+
* promises only what it does — every one reachable by Tab, every one saying
|
|
12
|
+
* whether it is on. Where a strip really does switch panels, it is a
|
|
13
|
+
* `UiTabsList` with `variant="pill"`, which draws the same pill.
|
|
14
|
+
*
|
|
15
|
+
* The `option` slot is for a label that is more than an icon and a word — the
|
|
16
|
+
* package managers swap their mark with the theme.
|
|
17
|
+
*/
|
|
18
|
+
export interface DuxtSegmentedOption<Value extends string = string> {
|
|
19
|
+
value: Value;
|
|
20
|
+
label: string;
|
|
21
|
+
icon?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const props = withDefaults(
|
|
25
|
+
defineProps<{
|
|
26
|
+
options: readonly DuxtSegmentedOption<T>[];
|
|
27
|
+
/** The group's accessible name. */
|
|
28
|
+
label?: string;
|
|
29
|
+
size?: DuxtPillSize;
|
|
30
|
+
tone?: DuxtPillTone;
|
|
31
|
+
/** Monospaced labels, for things a reader would type: a media type, a client. */
|
|
32
|
+
mono?: boolean;
|
|
33
|
+
class?: HTMLAttributes['class'];
|
|
34
|
+
}>(),
|
|
35
|
+
{ size: 'md', tone: 'raised', mono: false }
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const model = defineModel<T>({ required: true });
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<div
|
|
43
|
+
role="group"
|
|
44
|
+
:aria-label="label"
|
|
45
|
+
:class="cn('flex items-center gap-1', props.class)"
|
|
46
|
+
>
|
|
47
|
+
<button
|
|
48
|
+
v-for="option in options"
|
|
49
|
+
:key="option.value"
|
|
50
|
+
type="button"
|
|
51
|
+
:aria-pressed="model === option.value"
|
|
52
|
+
:class="[
|
|
53
|
+
duxtPill(model === option.value, { size, tone }),
|
|
54
|
+
mono && 'font-mono'
|
|
55
|
+
]"
|
|
56
|
+
@click="model = option.value"
|
|
57
|
+
>
|
|
58
|
+
<slot name="option" :option="option" :active="model === option.value">
|
|
59
|
+
<Icon v-if="option.icon" :name="option.icon" class="size-3.5" />
|
|
60
|
+
{{ option.label }}
|
|
61
|
+
</slot>
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
</template>
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
* The shortcut sheet
|
|
3
|
+
* The shortcut sheet — on `?`, and on the control that opens it.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* the
|
|
5
|
+
* It lists what `useDuxtShortcuts()` reports as live — the same list, under the
|
|
6
|
+
* same policy, that the handlers bind — so it cannot advertise a key nothing
|
|
7
|
+
* listens to.
|
|
8
|
+
*
|
|
9
|
+
* Its open state lives in `useDuxtShortcutSheet()` rather than in a `ref` here,
|
|
10
|
+
* because `?` is no longer the only way in: `DuxtShortcutsTrigger` opens the
|
|
11
|
+
* same sheet from the header, and on a site with
|
|
12
|
+
* `shortcuts.singleCharacter: false` it is the only thing that can — there is
|
|
13
|
+
* no `?` binding left.
|
|
7
14
|
*/
|
|
8
|
-
const open =
|
|
15
|
+
const { open } = useDuxtShortcutSheet();
|
|
16
|
+
const { shortcuts, keys, on } = useDuxtShortcuts();
|
|
9
17
|
|
|
10
|
-
|
|
11
|
-
(event) => event.key === '?' && !event.metaKey && !event.ctrlKey,
|
|
12
|
-
() => (open.value = !open.value)
|
|
13
|
-
);
|
|
18
|
+
on('help', () => (open.value = !open.value));
|
|
14
19
|
</script>
|
|
15
20
|
|
|
16
21
|
<template>
|
|
@@ -25,14 +30,14 @@ onDuxtShortcut(
|
|
|
25
30
|
|
|
26
31
|
<ul class="flex flex-col gap-2 text-sm">
|
|
27
32
|
<li
|
|
28
|
-
v-for="shortcut in
|
|
29
|
-
:key="shortcut.
|
|
33
|
+
v-for="shortcut in shortcuts"
|
|
34
|
+
:key="shortcut.action"
|
|
30
35
|
class="flex items-center justify-between gap-4"
|
|
31
36
|
>
|
|
32
37
|
<span>{{ $t(shortcut.label) }}</span>
|
|
33
38
|
<span class="flex gap-1">
|
|
34
39
|
<kbd
|
|
35
|
-
v-for="key in shortcut
|
|
40
|
+
v-for="key in keys(shortcut)"
|
|
36
41
|
:key="key"
|
|
37
42
|
class="rounded border bg-muted px-1.5 py-0.5 font-mono text-xs"
|
|
38
43
|
>
|