@kirchdev/duxt 0.3.4 → 0.3.5-dev-main.44-5337cc1
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 +42 -10
- 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
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import type { DuxtResolvedSource } from '../../sources-resolve';
|
|
2
2
|
import { searchSources } from '../utils/search-scope';
|
|
3
|
+
// Imported rather than auto-imported, the same as `searchSources` above: this
|
|
4
|
+
// composable is covered by a plain node test, which has no Nuxt globals.
|
|
5
|
+
import { asText } from '../utils/duxt-text';
|
|
6
|
+
import { duxtPageSearchable } from '../utils/page-controls';
|
|
7
|
+
import { sourceDisplayNames } from '../utils/search-display';
|
|
8
|
+
import type { DuxtSearchProvenance } from '../utils/search-display';
|
|
3
9
|
import type { DuxtSearchSection } from '@duxt/composables/useFuzzySearch';
|
|
4
10
|
|
|
5
11
|
/** A hit, with the source it came out of. */
|
|
6
12
|
export interface DuxtSearchHit extends DuxtSearchSection {
|
|
7
|
-
/**
|
|
8
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Undefined on a site with one source: there is nothing to distinguish.
|
|
15
|
+
*
|
|
16
|
+
* A NAME and the two facts that qualify it, rather than the one string this
|
|
17
|
+
* used to carry. That string was `[repo, version].join(' ')` with the URL
|
|
18
|
+
* prefix behind it as a fallback, so the site's own documentation announced
|
|
19
|
+
* itself as `/` — see `sourceDisplayNames` for the ladder that replaced it.
|
|
20
|
+
*/
|
|
21
|
+
source?: DuxtSearchProvenance & { collection: string };
|
|
9
22
|
}
|
|
10
23
|
|
|
11
24
|
/**
|
|
@@ -16,13 +29,11 @@ export interface DuxtSearchHit extends DuxtSearchSection {
|
|
|
16
29
|
* the thing the `sources` model makes possible and a single-repository theme
|
|
17
30
|
* cannot do at all.
|
|
18
31
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* The source being read goes first in each round, because a reader searching
|
|
25
|
-
* inside a project usually means that project.
|
|
32
|
+
* INTERLEAVED, NOT GLOBALLY RANKED. Content ranks each collection separately,
|
|
33
|
+
* and those score spaces are not comparable. The lists are therefore
|
|
34
|
+
* interleaved by position and each hit carries its provenance. The source being
|
|
35
|
+
* read goes first in each round, because a reader searching inside a project
|
|
36
|
+
* usually means that project.
|
|
26
37
|
*
|
|
27
38
|
* ONE VERSION PER ARTEFACT. Searching every version returns each page as many
|
|
28
39
|
* times as there are versions, which buries the answer under its own history.
|
|
@@ -78,7 +89,8 @@ export function useDuxtSearch() {
|
|
|
78
89
|
const approximate = searchable.map((entry) => ({
|
|
79
90
|
entry,
|
|
80
91
|
search: useFuzzySearch(entry.collection as DuxtCollectionName, {
|
|
81
|
-
ignoredTags: ['table']
|
|
92
|
+
ignoredTags: ['table'],
|
|
93
|
+
...duxt.search?.fuzzy
|
|
82
94
|
}).search
|
|
83
95
|
}));
|
|
84
96
|
|
|
@@ -92,14 +104,33 @@ export function useDuxtSearch() {
|
|
|
92
104
|
)
|
|
93
105
|
);
|
|
94
106
|
|
|
95
|
-
|
|
107
|
+
/**
|
|
108
|
+
* What a collection holds, and what it holds back.
|
|
109
|
+
*
|
|
110
|
+
* `all` answers the translation question — which pages this edition has, so a
|
|
111
|
+
* worse language is not offered for one a better language already answers.
|
|
112
|
+
* `excluded` answers a different one: which pages wrote `search: false`, and
|
|
113
|
+
* so belong in no result list at all. Both are read in the one query that was
|
|
114
|
+
* already being made, because the index the FTS and the fuzzy pass search is
|
|
115
|
+
* built from Content's search sections and carries no frontmatter — a page
|
|
116
|
+
* can only be dropped here, by path, after the fact.
|
|
117
|
+
*/
|
|
118
|
+
const pagePaths = new Map<
|
|
119
|
+
string,
|
|
120
|
+
Promise<{ all: Set<string>; excluded: Set<string> }>
|
|
121
|
+
>();
|
|
96
122
|
function paths(collection: string) {
|
|
97
123
|
let pending = pagePaths.get(collection);
|
|
98
124
|
if (!pending) {
|
|
99
125
|
pending = queryCollection(collection as DuxtCollectionArg)
|
|
100
|
-
.select('path')
|
|
126
|
+
.select('path', 'search')
|
|
101
127
|
.all()
|
|
102
|
-
.then((pages) =>
|
|
128
|
+
.then((pages) => ({
|
|
129
|
+
all: new Set(pages.map((page) => page.path)),
|
|
130
|
+
excluded: new Set(
|
|
131
|
+
pages.filter((page) => !duxtPageSearchable(page)).map((p) => p.path)
|
|
132
|
+
)
|
|
133
|
+
}))
|
|
103
134
|
.catch((error) => {
|
|
104
135
|
pagePaths.delete(collection);
|
|
105
136
|
throw error;
|
|
@@ -111,17 +142,25 @@ export function useDuxtSearch() {
|
|
|
111
142
|
|
|
112
143
|
// A fallback may answer only for a PAGE absent from every better language,
|
|
113
144
|
// even when a heading or the search term exists only in the original.
|
|
145
|
+
//
|
|
146
|
+
// A page that opted out of search is hidden in its OWN edition too, which is
|
|
147
|
+
// the difference between the two halves of this set: one suppresses a
|
|
148
|
+
// duplicate, the other suppresses the page itself.
|
|
114
149
|
async function visiblePages(wanted: DuxtResolvedSource[]) {
|
|
115
150
|
return Promise.all(
|
|
116
151
|
wanted.map(async (entry, index) => {
|
|
117
152
|
const preferred = wanted
|
|
118
153
|
.slice(0, index)
|
|
119
154
|
.filter((other) => other.prefix === entry.prefix);
|
|
120
|
-
|
|
121
|
-
(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
155
|
+
const [covered, own] = await Promise.all([
|
|
156
|
+
Promise.all(preferred.map((other) => paths(other.collection))),
|
|
157
|
+
paths(entry.collection)
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
return new Set([
|
|
161
|
+
...covered.flatMap((pages) => [...pages.all]),
|
|
162
|
+
...own.excluded
|
|
163
|
+
]);
|
|
125
164
|
})
|
|
126
165
|
);
|
|
127
166
|
}
|
|
@@ -131,10 +170,32 @@ export function useDuxtSearch() {
|
|
|
131
170
|
() => new Set(active.value.map((entry) => entry.prefix)).size > 1
|
|
132
171
|
);
|
|
133
172
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
173
|
+
/**
|
|
174
|
+
* What each collection is CALLED, by collection name.
|
|
175
|
+
*
|
|
176
|
+
* Computed over the whole searchable set once rather than per hit: the
|
|
177
|
+
* collision qualifier needs to see every source at the same time to know
|
|
178
|
+
* whether a name is ambiguous at all.
|
|
179
|
+
*/
|
|
180
|
+
const names = computed(() =>
|
|
181
|
+
sourceDisplayNames(searchable, asText(duxt.title) ?? '')
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const provenanceOf = (
|
|
185
|
+
entry: (typeof searchable)[number]
|
|
186
|
+
): DuxtSearchProvenance => ({
|
|
187
|
+
name: names.value.get(entry.collection) ?? '',
|
|
188
|
+
// The artefact keeps its own label beside the source's name: a reader
|
|
189
|
+
// looking at a hit from a changelog wants to be told it is the changelog,
|
|
190
|
+
// and which project's changelog it is.
|
|
191
|
+
artefact: entry.generated?.label,
|
|
192
|
+
version: entry.version
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const sourceOf = (entry: (typeof searchable)[number]) =>
|
|
196
|
+
labelled.value
|
|
197
|
+
? { ...provenanceOf(entry), collection: entry.collection }
|
|
198
|
+
: undefined;
|
|
138
199
|
|
|
139
200
|
async function init() {
|
|
140
201
|
await Promise.all(
|
|
@@ -151,7 +212,8 @@ export function useDuxtSearch() {
|
|
|
151
212
|
* Without scores to compare across collections — each database ranks inside
|
|
152
213
|
* itself — position is the only comparable thing there is. Round-robin over
|
|
153
214
|
* the lists gives every source its best hit before any source gets its
|
|
154
|
-
* second
|
|
215
|
+
* second. The result preserves each collection's local order without
|
|
216
|
+
* pretending its score is comparable to another collection's.
|
|
155
217
|
*/
|
|
156
218
|
function interleave(lists: DuxtSearchHit[][], limit: number) {
|
|
157
219
|
const merged: DuxtSearchHit[] = [];
|
|
@@ -210,12 +272,7 @@ export function useDuxtSearch() {
|
|
|
210
272
|
|
|
211
273
|
return hits.map((hit) => ({
|
|
212
274
|
...hit,
|
|
213
|
-
source:
|
|
214
|
-
? {
|
|
215
|
-
label: labelOf(entry.entry),
|
|
216
|
-
collection: entry.entry.collection
|
|
217
|
-
}
|
|
218
|
-
: undefined
|
|
275
|
+
source: sourceOf(entry.entry)
|
|
219
276
|
}));
|
|
220
277
|
})
|
|
221
278
|
);
|
|
@@ -237,12 +294,7 @@ export function useDuxtSearch() {
|
|
|
237
294
|
|
|
238
295
|
return hits.map((hit) => ({
|
|
239
296
|
...hit,
|
|
240
|
-
source:
|
|
241
|
-
? {
|
|
242
|
-
label: labelOf(entry.entry),
|
|
243
|
-
collection: entry.entry.collection
|
|
244
|
-
}
|
|
245
|
-
: undefined
|
|
297
|
+
source: sourceOf(entry.entry)
|
|
246
298
|
}));
|
|
247
299
|
})
|
|
248
300
|
);
|
|
@@ -29,9 +29,23 @@ export function useDuxtSection(
|
|
|
29
29
|
* in the prose beside it — so the sidebar drew that tree instead of the
|
|
30
30
|
* endpoints. Same rule, and the same bug, as `sourceForPath`.
|
|
31
31
|
*/
|
|
32
|
+
/**
|
|
33
|
+
* AT THE READER'S EDITION. The sections are written once, at the default
|
|
34
|
+
* edition — `/concepts` — and on `/v0.2.0/concepts/sources` nothing in that
|
|
35
|
+
* list prefixes the page: no branch was found and the sidebar drew the whole
|
|
36
|
+
* tree, every section at once, on every other version. `sectionsForPath`
|
|
37
|
+
* moves them to the edition being read, the same list the section row shows.
|
|
38
|
+
*/
|
|
32
39
|
const section = computed(() =>
|
|
33
40
|
currentSection(
|
|
34
|
-
[
|
|
41
|
+
[
|
|
42
|
+
...sectionsForPath(
|
|
43
|
+
duxt.sections ?? [],
|
|
44
|
+
duxt.resolvedSources ?? [],
|
|
45
|
+
path.value
|
|
46
|
+
),
|
|
47
|
+
...(duxt.navigation ?? [])
|
|
48
|
+
],
|
|
35
49
|
path.value
|
|
36
50
|
)
|
|
37
51
|
);
|
|
@@ -33,11 +33,12 @@ export function useDuxtSectionRow() {
|
|
|
33
33
|
* where there is one — as a length the layouts hand down in
|
|
34
34
|
* `--duxt-header-offset`.
|
|
35
35
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* and a
|
|
39
|
-
*
|
|
40
|
-
*
|
|
36
|
+
* NAMED FOR WHAT IT MEASURES, not for an umbrella word. What the layer draws
|
|
37
|
+
* around a page is named as the specific thing — the header, the navigation,
|
|
38
|
+
* the aside, the docs shell — and what a page may turn off is a page control,
|
|
39
|
+
* because a name that has to be explained is a name chosen badly.
|
|
40
|
+
* `DuxtLiveWindow`'s `chrome` prop is the one exception, because there it
|
|
41
|
+
* really is a drawn browser bar.
|
|
41
42
|
*
|
|
42
43
|
* A CSS variable rather than a class per case: the sticky columns live in
|
|
43
44
|
* three layouts and in the page's own contents column, and four copies of a
|
|
@@ -1,32 +1,139 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* One place that knows which keys the theme listens to.
|
|
2
|
+
* One place that knows which keys the theme listens to — and when it may.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Four things used to be able to disagree: the sheet on `?`, the handlers, the
|
|
5
|
+
* guards each handler remembered for itself, and a binding table a consumer
|
|
6
|
+
* could rewrite. They are one definition now. A consumer keeps the decision
|
|
7
|
+
* that is genuinely its own — whether unmodified keys are bound at all — and
|
|
8
|
+
* that is a boolean, which cannot drift from a list it is not.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* The guards matter as much as the bindings. A global single-character
|
|
11
|
+
* shortcut is a key the reader never opted into: it has to stand down while
|
|
12
|
+
* they are typing, while a dialog or menu owns the keyboard, when something
|
|
13
|
+
* nearer the event has already handled it, and whenever focus is on a control
|
|
14
|
+
* rather than on the page itself. `⌘/Ctrl+K` is the one exception to the last
|
|
15
|
+
* of those, because no control wants that chord.
|
|
11
16
|
*/
|
|
17
|
+
export type DuxtShortcutAction = 'search' | 'help' | 'previous' | 'next';
|
|
18
|
+
|
|
12
19
|
export interface DuxtShortcut {
|
|
20
|
+
action: DuxtShortcutAction;
|
|
13
21
|
/** The key, as `KeyboardEvent.key` reports it. */
|
|
14
22
|
key: string;
|
|
15
23
|
/** Requires ⌘ on a Mac, Ctrl elsewhere. */
|
|
16
24
|
meta?: boolean;
|
|
17
25
|
/** i18n key for what it does. */
|
|
18
26
|
label: string;
|
|
19
|
-
/** How to draw it. */
|
|
20
|
-
keys: string[];
|
|
21
27
|
}
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The bindings, and the only place they are written down.
|
|
31
|
+
*
|
|
32
|
+
* Not `app.config`: a table of keys a consumer may rewrite is a table the
|
|
33
|
+
* sheet, the handlers and the guards each have to agree with, and the whole
|
|
34
|
+
* defect this replaces was those four drifting apart. What a consumer owns is
|
|
35
|
+
* the POLICY below — whether the unmodified keys are live at all — which is
|
|
36
|
+
* one boolean and has nothing to drift from.
|
|
37
|
+
*/
|
|
38
|
+
export const duxtShortcuts: readonly DuxtShortcut[] = [
|
|
39
|
+
{ action: 'search', key: 'k', meta: true, label: 'duxt.shortcuts.search' },
|
|
40
|
+
{ action: 'help', key: '?', label: 'duxt.shortcuts.help' },
|
|
41
|
+
{ action: 'previous', key: '[', label: 'duxt.shortcuts.previous' },
|
|
42
|
+
{ action: 'next', key: ']', label: 'duxt.shortcuts.next' }
|
|
28
43
|
];
|
|
29
44
|
|
|
45
|
+
/** Which modifier the reader's keyboard actually carries. */
|
|
46
|
+
export type DuxtPlatform = 'mac' | 'other';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Apple or not, from whatever the browser is willing to say.
|
|
50
|
+
*
|
|
51
|
+
* `userAgentData` first because `navigator.platform` is deprecated and frozen
|
|
52
|
+
* — Chrome reports `MacIntel` from an iPad and `Win32` from anything it has
|
|
53
|
+
* decided to lie to. No browser at all (the server, and the first paint of a
|
|
54
|
+
* prerendered page) answers `other`: `Ctrl` is the safe thing to have drawn if
|
|
55
|
+
* the guess has to be wrong, since it is what the larger half of readers use.
|
|
56
|
+
*/
|
|
57
|
+
export function duxtPlatform(navigatorLike?: {
|
|
58
|
+
platform?: string;
|
|
59
|
+
userAgentData?: { platform?: string };
|
|
60
|
+
}): DuxtPlatform {
|
|
61
|
+
const reported =
|
|
62
|
+
navigatorLike?.userAgentData?.platform ?? navigatorLike?.platform ?? '';
|
|
63
|
+
|
|
64
|
+
return /mac|iphone|ipad|ipod/i.test(reported) ? 'mac' : 'other';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* How to draw a binding — the only place a key becomes glyphs.
|
|
69
|
+
*
|
|
70
|
+
* Derived rather than stored, so a binding cannot come to advertise a key it
|
|
71
|
+
* does not listen to, and so `⌘` and `Ctrl` are one branch instead of two
|
|
72
|
+
* entries somebody has to keep in step.
|
|
73
|
+
*/
|
|
74
|
+
export function shortcutKeys(
|
|
75
|
+
shortcut: Pick<DuxtShortcut, 'key' | 'meta'>,
|
|
76
|
+
platform: DuxtPlatform
|
|
77
|
+
): string[] {
|
|
78
|
+
const key =
|
|
79
|
+
shortcut.key.length === 1 ? shortcut.key.toUpperCase() : shortcut.key;
|
|
80
|
+
|
|
81
|
+
return shortcut.meta ? [platform === 'mac' ? '⌘' : 'Ctrl', key] : [key];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The same keys on one line, for a hint drawn inside a control.
|
|
86
|
+
*
|
|
87
|
+
* `⌘K` needs no separator and reads worse with one; `Ctrl` is a word, and
|
|
88
|
+
* `CtrlK` is not a thing anybody has ever pressed.
|
|
89
|
+
*/
|
|
90
|
+
export const shortcutHint = (keys: string[]): string =>
|
|
91
|
+
keys.join((keys[0]?.length ?? 0) > 1 ? '+' : '');
|
|
92
|
+
|
|
93
|
+
/** What a consumer decides about the keys duxt listens to globally. */
|
|
94
|
+
export interface DuxtShortcutPolicy {
|
|
95
|
+
/**
|
|
96
|
+
* Whether a bare keystroke — `?`, `[`, `]` — triggers anything at all.
|
|
97
|
+
*
|
|
98
|
+
* A global single-character shortcut is the one binding that can fire under
|
|
99
|
+
* a reader who never asked for it: switch access, voice control and dictation
|
|
100
|
+
* all emit bare characters as a matter of course, and a page that navigates
|
|
101
|
+
* away under them shows no cause. `false` leaves `⌘/Ctrl+K`, which no input
|
|
102
|
+
* method produces by accident.
|
|
103
|
+
*/
|
|
104
|
+
singleCharacter?: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One unmodified keystroke — the tier a consumer can switch off. */
|
|
108
|
+
export const isSingleCharacterShortcut = (
|
|
109
|
+
shortcut: Pick<DuxtShortcut, 'meta'>
|
|
110
|
+
) => !shortcut.meta;
|
|
111
|
+
|
|
112
|
+
/** The bindings that are live under a consumer's policy. */
|
|
113
|
+
export function activeDuxtShortcuts(
|
|
114
|
+
policy?: DuxtShortcutPolicy
|
|
115
|
+
): readonly DuxtShortcut[] {
|
|
116
|
+
return policy?.singleCharacter === false
|
|
117
|
+
? duxtShortcuts.filter((shortcut) => !isSingleCharacterShortcut(shortcut))
|
|
118
|
+
: duxtShortcuts;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The live binding behind one action, or nothing.
|
|
123
|
+
*
|
|
124
|
+
* Every visible hint goes through this rather than through `duxtShortcuts`
|
|
125
|
+
* directly, and the difference is the whole point: a hint read off the raw
|
|
126
|
+
* table would keep drawing `[` on a site whose policy unbound it — a label for
|
|
127
|
+
* a key that does nothing, which is the sheet's own defect one level further
|
|
128
|
+
* out.
|
|
129
|
+
*/
|
|
130
|
+
export function duxtShortcutFor<T extends Pick<DuxtShortcut, 'action'>>(
|
|
131
|
+
shortcuts: readonly T[],
|
|
132
|
+
action: DuxtShortcutAction
|
|
133
|
+
): T | undefined {
|
|
134
|
+
return shortcuts.find((shortcut) => shortcut.action === action);
|
|
135
|
+
}
|
|
136
|
+
|
|
30
137
|
/** Is the reader typing? Then the key belongs to whatever they are typing in. */
|
|
31
138
|
export function isTyping(target: EventTarget | null): boolean {
|
|
32
139
|
const element = target as HTMLElement | null;
|
|
@@ -38,21 +145,213 @@ export function isTyping(target: EventTarget | null): boolean {
|
|
|
38
145
|
);
|
|
39
146
|
}
|
|
40
147
|
|
|
41
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* Widgets that answer keystrokes themselves, so a global binding must not.
|
|
150
|
+
*
|
|
151
|
+
* Ancestry rather than the focused element alone: a dialog's keys belong to the
|
|
152
|
+
* dialog wherever focus has landed inside it, and a contenteditable region is
|
|
153
|
+
* being typed into even when the event target is some `<span>` within it.
|
|
154
|
+
*/
|
|
155
|
+
const OWNING_SELECTOR = [
|
|
156
|
+
'input',
|
|
157
|
+
'textarea',
|
|
158
|
+
'select',
|
|
159
|
+
'[contenteditable="true"]',
|
|
160
|
+
'dialog',
|
|
161
|
+
'[role="dialog"]',
|
|
162
|
+
'[role="alertdialog"]',
|
|
163
|
+
'[role="menu"]',
|
|
164
|
+
'[role="menubar"]',
|
|
165
|
+
'[role="listbox"]',
|
|
166
|
+
'[role="combobox"]',
|
|
167
|
+
'[role="grid"]',
|
|
168
|
+
'[role="treegrid"]',
|
|
169
|
+
'[role="tree"]'
|
|
170
|
+
].join(',');
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Anything the reader can operate — which is not the same set as above.
|
|
174
|
+
*
|
|
175
|
+
* A link or a button owns no keystroke, so `⌘/Ctrl+K` is still the reader's to
|
|
176
|
+
* press while one has focus. A BARE character is different: it is the keystroke
|
|
177
|
+
* a control is most likely to want next, so the unmodified tier stands down
|
|
178
|
+
* here and fires only on the page's own content.
|
|
179
|
+
*
|
|
180
|
+
* `tabindex="-1"` is excluded deliberately — that is the focus target
|
|
181
|
+
* `useDuxtPageFocus` moves to after a navigation, and a reader who has just
|
|
182
|
+
* arrived on a page is exactly who `[` and `]` are for.
|
|
183
|
+
*/
|
|
184
|
+
const INTERACTIVE_SELECTOR = [
|
|
185
|
+
'a[href]',
|
|
186
|
+
'button',
|
|
187
|
+
'summary',
|
|
188
|
+
'details',
|
|
189
|
+
'audio',
|
|
190
|
+
'video',
|
|
191
|
+
'[tabindex]:not([tabindex^="-"])',
|
|
192
|
+
'[role="button"]',
|
|
193
|
+
'[role="link"]',
|
|
194
|
+
'[role="checkbox"]',
|
|
195
|
+
'[role="radio"]',
|
|
196
|
+
'[role="switch"]',
|
|
197
|
+
'[role="slider"]',
|
|
198
|
+
'[role="spinbutton"]',
|
|
199
|
+
'[role="tab"]',
|
|
200
|
+
'[role="option"]',
|
|
201
|
+
'[role="menuitem"]',
|
|
202
|
+
'[role="textbox"]'
|
|
203
|
+
].join(',');
|
|
204
|
+
|
|
205
|
+
const within = (target: EventTarget | null, selector: string): boolean => {
|
|
206
|
+
const element = target as Element | null;
|
|
207
|
+
|
|
208
|
+
return typeof element?.closest === 'function'
|
|
209
|
+
? element.closest(selector) !== null
|
|
210
|
+
: false;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/** Focus sits in something that answers this keystroke itself. */
|
|
214
|
+
export const ownsKeyboard = (target: EventTarget | null): boolean =>
|
|
215
|
+
isTyping(target) || within(target, OWNING_SELECTOR);
|
|
216
|
+
|
|
217
|
+
/** Focus is on a control rather than on the page's own content. */
|
|
218
|
+
export const isInteractiveTarget = (target: EventTarget | null): boolean =>
|
|
219
|
+
within(target, INTERACTIVE_SELECTOR);
|
|
220
|
+
|
|
221
|
+
/** What matching reads off a keydown — the whole event is never needed. */
|
|
222
|
+
export type DuxtShortcutEvent = Pick<
|
|
223
|
+
KeyboardEvent,
|
|
224
|
+
'key' | 'metaKey' | 'ctrlKey' | 'defaultPrevented' | 'target'
|
|
225
|
+
>;
|
|
226
|
+
|
|
227
|
+
const matchesKey = (
|
|
228
|
+
shortcut: Pick<DuxtShortcut, 'key' | 'meta'>,
|
|
229
|
+
event: DuxtShortcutEvent
|
|
230
|
+
) =>
|
|
231
|
+
shortcut.key.toLowerCase() === event.key.toLowerCase() &&
|
|
232
|
+
Boolean(shortcut.meta) === Boolean(event.metaKey || event.ctrlKey);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The shortcut this keystroke fires, or nothing — the one gate every binding
|
|
236
|
+
* passes through.
|
|
237
|
+
*
|
|
238
|
+
* Every guard lives here rather than at the four call sites, because a guard
|
|
239
|
+
* that only three of them remembered is the bug this replaces.
|
|
240
|
+
*/
|
|
241
|
+
export function findDuxtShortcut<
|
|
242
|
+
T extends Pick<DuxtShortcut, 'action' | 'key' | 'meta'>
|
|
243
|
+
>(shortcuts: readonly T[], event: DuxtShortcutEvent): T | undefined {
|
|
244
|
+
// Something nearer the key has already answered it.
|
|
245
|
+
if (event.defaultPrevented) return undefined;
|
|
246
|
+
if (ownsKeyboard(event.target)) return undefined;
|
|
247
|
+
|
|
248
|
+
const interactive = isInteractiveTarget(event.target);
|
|
249
|
+
|
|
250
|
+
return shortcuts.find(
|
|
251
|
+
(shortcut) =>
|
|
252
|
+
matchesKey(shortcut, event) &&
|
|
253
|
+
!(interactive && isSingleCharacterShortcut(shortcut))
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Bind a set of actions, with every guard the policy requires.
|
|
259
|
+
*
|
|
260
|
+
* Takes a getter rather than a list: the policy is reactive config, and a
|
|
261
|
+
* binding captured once would keep firing for a key the site has since
|
|
262
|
+
* switched off.
|
|
263
|
+
*/
|
|
42
264
|
export function onDuxtShortcut(
|
|
43
|
-
|
|
44
|
-
run: (event: KeyboardEvent) => void
|
|
265
|
+
shortcuts: () => readonly DuxtShortcut[],
|
|
266
|
+
run: (action: DuxtShortcutAction, event: KeyboardEvent) => void
|
|
45
267
|
) {
|
|
46
268
|
onMounted(() => {
|
|
47
269
|
const handler = (event: KeyboardEvent) => {
|
|
48
|
-
|
|
49
|
-
if (!
|
|
270
|
+
const shortcut = findDuxtShortcut(shortcuts(), event);
|
|
271
|
+
if (!shortcut) return;
|
|
50
272
|
|
|
51
273
|
event.preventDefault();
|
|
52
|
-
run(event);
|
|
274
|
+
run(shortcut.action, event);
|
|
53
275
|
};
|
|
54
276
|
|
|
55
277
|
window.addEventListener('keydown', handler);
|
|
56
278
|
onBeforeUnmount(() => window.removeEventListener('keydown', handler));
|
|
57
279
|
});
|
|
58
280
|
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The shortcuts this site actually has, how to draw them, and how to bind one.
|
|
284
|
+
*
|
|
285
|
+
* The single entry point for every component that cares: the sheet renders
|
|
286
|
+
* `shortcuts`, the handlers call `on()`, and both read the same list under the
|
|
287
|
+
* same policy — which is what stops a sheet advertising a key nothing listens
|
|
288
|
+
* to.
|
|
289
|
+
*/
|
|
290
|
+
export function useDuxtShortcuts() {
|
|
291
|
+
const duxt = useDuxtConfig();
|
|
292
|
+
|
|
293
|
+
// After mount, never during render. The server has no keyboard to ask, and a
|
|
294
|
+
// prerendered page is a file — so the modifier is resolved once the reader's
|
|
295
|
+
// own browser is there to answer, and `Ctrl` stands until it does.
|
|
296
|
+
const platform = ref<DuxtPlatform>('other');
|
|
297
|
+
onMounted(() => {
|
|
298
|
+
platform.value = duxtPlatform(globalThis.navigator);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const shortcuts = computed(() => activeDuxtShortcuts(duxt.shortcuts));
|
|
302
|
+
|
|
303
|
+
/** How to draw one, on the keyboard the reader is actually holding. */
|
|
304
|
+
const keys = (shortcut: Pick<DuxtShortcut, 'key' | 'meta'>) =>
|
|
305
|
+
shortcutKeys(shortcut, platform.value);
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* What to print beside a control, or nothing where the key is not bound.
|
|
309
|
+
*
|
|
310
|
+
* `undefined` rather than an empty string, so a template writes
|
|
311
|
+
* `v-if="hint('next')"` and draws no `<kbd>` at all — an empty key cap beside
|
|
312
|
+
* a link is worse than no hint, and a site with `singleCharacter: false` has
|
|
313
|
+
* two of them on every page.
|
|
314
|
+
*/
|
|
315
|
+
const hint = (action: DuxtShortcutAction) => {
|
|
316
|
+
const shortcut = duxtShortcutFor(shortcuts.value, action);
|
|
317
|
+
|
|
318
|
+
return shortcut ? shortcutHint(keys(shortcut)) : undefined;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/** Bind one action, or several that share a handler. */
|
|
322
|
+
const on = (
|
|
323
|
+
actions: DuxtShortcutAction | DuxtShortcutAction[],
|
|
324
|
+
run: (action: DuxtShortcutAction, event: KeyboardEvent) => void
|
|
325
|
+
) => {
|
|
326
|
+
const wanted = Array.isArray(actions) ? actions : [actions];
|
|
327
|
+
|
|
328
|
+
onDuxtShortcut(
|
|
329
|
+
() =>
|
|
330
|
+
shortcuts.value.filter((shortcut) => wanted.includes(shortcut.action)),
|
|
331
|
+
run
|
|
332
|
+
);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
return { shortcuts, keys, hint, on, platform };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Whether the shortcut sheet is open — shared, because the key is no longer the
|
|
340
|
+
* only way in.
|
|
341
|
+
*
|
|
342
|
+
* The sheet used to own a `ref` of its own, which worked while `?` was the only
|
|
343
|
+
* opener. A visible control has to reach the same boolean from elsewhere in the
|
|
344
|
+
* tree, and it has to be ONE boolean: a second instance of the sheet would be a
|
|
345
|
+
* second dialog, and two dialogs listening to `?` is the drift this file
|
|
346
|
+
* exists to prevent. `useState` rather than a module-level `ref` because a
|
|
347
|
+
* module-level one is shared between requests on the server.
|
|
348
|
+
*
|
|
349
|
+
* It matters most where `?` is not bound at all. With
|
|
350
|
+
* `shortcuts.singleCharacter: false` the visible control is the ONLY entry
|
|
351
|
+
* point, so nothing about the sheet may depend on the key having fired.
|
|
352
|
+
*/
|
|
353
|
+
export function useDuxtShortcutSheet() {
|
|
354
|
+
const open = useState('duxt-shortcuts', () => false);
|
|
355
|
+
|
|
356
|
+
return { open, toggle: () => (open.value = !open.value) };
|
|
357
|
+
}
|