@kirchdev/duxt 0.3.4-dev-main.41-23ded41 → 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
|
@@ -4,12 +4,18 @@ import {
|
|
|
4
4
|
sourcesForRoute,
|
|
5
5
|
type DuxtResolvedSource
|
|
6
6
|
} from '../../sources-resolve';
|
|
7
|
+
import { duxtPageSearchable } from '../../app/utils/page-controls';
|
|
7
8
|
|
|
8
9
|
/** Enumerate public pages, selecting the same collection chain as HTML. */
|
|
9
10
|
export async function llmsPages(
|
|
10
11
|
event: H3Event,
|
|
11
|
-
sources: DuxtResolvedSource[] = []
|
|
12
|
+
sources: DuxtResolvedSource[] = [],
|
|
13
|
+
includeRawbody = false
|
|
12
14
|
) {
|
|
15
|
+
// llms.txt is a map of the documentation a reader should start with. Older
|
|
16
|
+
// versions stay published at their own URLs, but making an agent choose
|
|
17
|
+
// between them repeats a decision the source manifest has already made.
|
|
18
|
+
const defaultSources = sources.filter((source) => source.isDefault);
|
|
13
19
|
const i18n = (
|
|
14
20
|
useRuntimeConfig(event).public as unknown as {
|
|
15
21
|
i18n?: {
|
|
@@ -29,19 +35,28 @@ export async function llmsPages(
|
|
|
29
35
|
const locales =
|
|
30
36
|
strategy === 'no_prefix' || !codes.length ? [defaultLocale] : codes;
|
|
31
37
|
const collections = sources.length
|
|
32
|
-
? [...new Set(
|
|
38
|
+
? [...new Set(defaultSources.map((source) => source.collection))]
|
|
33
39
|
: ['docs'];
|
|
40
|
+
// `search` travels with every row so the opt-out is applied once, here, and
|
|
41
|
+
// not in the two routes: llms.txt and llms-full.txt are one index in two
|
|
42
|
+
// shapes, and a page hidden from one but listed in the other is a bug nobody
|
|
43
|
+
// would go looking for.
|
|
44
|
+
const fields = includeRawbody
|
|
45
|
+
? (['path', 'title', 'description', 'rawbody', 'search'] as const)
|
|
46
|
+
: (['path', 'title', 'description', 'search'] as const);
|
|
34
47
|
const entries = await Promise.all(
|
|
35
48
|
collections.map(
|
|
36
49
|
async (name) =>
|
|
37
50
|
[
|
|
38
51
|
name,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
52
|
+
(
|
|
53
|
+
await queryCollection(
|
|
54
|
+
event,
|
|
55
|
+
name as Parameters<typeof queryCollection>[1]
|
|
56
|
+
)
|
|
57
|
+
.select(...fields)
|
|
58
|
+
.all()
|
|
59
|
+
).filter((page) => duxtPageSearchable(page))
|
|
45
60
|
] as const
|
|
46
61
|
)
|
|
47
62
|
);
|
|
@@ -63,7 +78,7 @@ export async function llmsPages(
|
|
|
63
78
|
for (const locale of locales) {
|
|
64
79
|
for (const path of paths) {
|
|
65
80
|
const chain = sources.length
|
|
66
|
-
? sourcesForRoute(path, locale,
|
|
81
|
+
? sourcesForRoute(path, locale, defaultSources, fallbackLocale).map(
|
|
67
82
|
(source) => source.collection
|
|
68
83
|
)
|
|
69
84
|
: ['docs'];
|
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { queryCollection } from '@nuxt/content/nitro';
|
|
2
|
+
import type { H3Event } from 'h3';
|
|
3
|
+
import { splitLocalePath } from '../../app/utils/locale-path';
|
|
4
|
+
import { isInside } from '../../app/utils/version-paths';
|
|
5
|
+
import {
|
|
6
|
+
localeChain,
|
|
7
|
+
sourcesForRoute,
|
|
8
|
+
type DuxtResolvedSource
|
|
9
|
+
} from '../../sources-resolve';
|
|
10
|
+
import { stripFrontmatter } from '../../frontmatter';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What the four MCP tools know about this site's documentation.
|
|
14
|
+
*
|
|
15
|
+
* Lifted out of the tools because all three of them opened with the SAME
|
|
16
|
+
* private `duxtCollections()` — a copy each, all three assuming one flat pile
|
|
17
|
+
* of pages. A versioned, translated site is not that: `/app` and `/app/v1` are
|
|
18
|
+
* different editions of one page, and asking every collection for a path
|
|
19
|
+
* answers with whichever sorted first. The tools now share one model of the
|
|
20
|
+
* manifest, and what an agent scopes by is a URL PREFIX — the thing it can
|
|
21
|
+
* also paste into a browser, rather than a collection name that exists nowhere
|
|
22
|
+
* else.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A site that declares no sources at all still has one collection.
|
|
27
|
+
*
|
|
28
|
+
* Content's own default is `docs`, and the three tools each open-coded that
|
|
29
|
+
* fallback. Synthesised as a real entry instead, so the scope, the locale
|
|
30
|
+
* chain and the page loader have one code path rather than two.
|
|
31
|
+
*/
|
|
32
|
+
const BARE: DuxtResolvedSource = {
|
|
33
|
+
collection: 'docs',
|
|
34
|
+
prefix: '',
|
|
35
|
+
path: 'docs',
|
|
36
|
+
isDefault: true,
|
|
37
|
+
isDefaultLocale: true,
|
|
38
|
+
status: 'current',
|
|
39
|
+
history: false
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The request behind an MCP tool call.
|
|
44
|
+
*
|
|
45
|
+
* NOT `extra.event`, which is what all four tools read for their first nine
|
|
46
|
+
* days and which has never existed. `@nuxtjs/mcp-toolkit` hands a tool handler
|
|
47
|
+
* the MCP SDK's own `RequestHandlerExtra` — `signal`, `requestId`,
|
|
48
|
+
* `sessionId`, `authInfo`, `requestInfo`, `sendNotification`, `sendRequest` —
|
|
49
|
+
* and nothing of H3's. The read was silently `undefined`, and the two things
|
|
50
|
+
* it was passed to both tolerate that: `queryCollection(undefined, …)` falls
|
|
51
|
+
* back to the global `$fetch` and `useRuntimeConfig(undefined)` returns the
|
|
52
|
+
* global config. So `/mcp` answered, wrongly but visibly enough, until
|
|
53
|
+
* `duxtLocaleSetup` began dereferencing `event.context` for the i18n fallback
|
|
54
|
+
* — from which point EVERY tool call answered `Cannot read properties of
|
|
55
|
+
* undefined (reading 'context')` as an `isError` result. Nothing caught it:
|
|
56
|
+
* the unit tests hand the handlers an `extra` of their own invention with an
|
|
57
|
+
* `event` on it, and `pnpm check` probes no MCP endpoint at all.
|
|
58
|
+
*
|
|
59
|
+
* `useEvent()` is how the toolkit itself reaches the request — `useMcpServer`,
|
|
60
|
+
* `useMcpSession`, its logger and its notifier all call it — and it is why the
|
|
61
|
+
* layer turns `nitro.experimental.asyncContext` on in `nuxt.config.ts`.
|
|
62
|
+
*
|
|
63
|
+
* Having the REAL event, rather than teaching the tools to live without one,
|
|
64
|
+
* is the point: the four tools then read the database through exactly the code
|
|
65
|
+
* path `llms.txt` and the `.md` middleware take — `event.$fetch` and the
|
|
66
|
+
* request's own i18n fallback — instead of a second one that only `/mcp` runs
|
|
67
|
+
* and that only a Worker would have disagreed with.
|
|
68
|
+
*/
|
|
69
|
+
export function duxtMcpEvent(): H3Event {
|
|
70
|
+
return useEvent();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The resolved manifest, or the single collection a bare site publishes. */
|
|
74
|
+
export function duxtSources(): DuxtResolvedSource[] {
|
|
75
|
+
const { duxt } = useAppConfig() as { duxt?: Partial<DuxtConfig> };
|
|
76
|
+
const sources = duxt?.resolvedSources ?? [];
|
|
77
|
+
|
|
78
|
+
return sources.length ? sources : [BARE];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** How this site turns a locale into a URL segment. */
|
|
82
|
+
export interface DuxtLocaleSetup {
|
|
83
|
+
/** The codes the site serves, in declaration order. */
|
|
84
|
+
codes: string[];
|
|
85
|
+
/** The code served without a segment under `prefix_except_default`. */
|
|
86
|
+
defaultLocale?: string;
|
|
87
|
+
/** `@nuxtjs/i18n`'s routing strategy. */
|
|
88
|
+
strategy: string;
|
|
89
|
+
/** What a missing translation falls back to before the original. */
|
|
90
|
+
fallbackLocale?: string | string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Read that off the request.
|
|
95
|
+
*
|
|
96
|
+
* The same three reads `llms-pages` and the `.md` middleware each do inline —
|
|
97
|
+
* runtime config for the locales and the strategy, the request context for the
|
|
98
|
+
* fallback, which Nuxt i18n only merges in its own request hook.
|
|
99
|
+
*/
|
|
100
|
+
export function duxtLocaleSetup(event: H3Event): DuxtLocaleSetup {
|
|
101
|
+
const i18n = (
|
|
102
|
+
useRuntimeConfig(event).public as unknown as {
|
|
103
|
+
i18n?: {
|
|
104
|
+
locales?: (string | { code: string })[];
|
|
105
|
+
defaultLocale?: string;
|
|
106
|
+
strategy?: string;
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
).i18n;
|
|
110
|
+
const codes = (i18n?.locales ?? []).map((locale) =>
|
|
111
|
+
typeof locale === 'string' ? locale : locale.code
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
codes,
|
|
116
|
+
defaultLocale: i18n?.defaultLocale ?? codes[0],
|
|
117
|
+
strategy: i18n?.strategy ?? 'prefix_except_default',
|
|
118
|
+
fallbackLocale: event.context.nuxtI18n?.vueI18nOptions?.fallbackLocale as
|
|
119
|
+
| string
|
|
120
|
+
| string[]
|
|
121
|
+
| undefined
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A documentation path as the site serves it, locale segment included.
|
|
127
|
+
*
|
|
128
|
+
* `prefix_except_default` is the interesting one: the default language keeps
|
|
129
|
+
* the bare path, every other language gets a segment in front of it. Same rule
|
|
130
|
+
* `llms-pages` applies when it enumerates the site for `llms.txt`.
|
|
131
|
+
*/
|
|
132
|
+
export function duxtPublicPath(
|
|
133
|
+
path: string,
|
|
134
|
+
locale: string | undefined,
|
|
135
|
+
setup: DuxtLocaleSetup
|
|
136
|
+
): string {
|
|
137
|
+
const prefixed =
|
|
138
|
+
locale &&
|
|
139
|
+
setup.strategy !== 'no_prefix' &&
|
|
140
|
+
(setup.strategy !== 'prefix_except_default' ||
|
|
141
|
+
locale !== setup.defaultLocale);
|
|
142
|
+
|
|
143
|
+
if (!prefixed) return path || '/';
|
|
144
|
+
|
|
145
|
+
return `/${locale}${path === '/' ? '' : path}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** One language a version is published in, and the prefix that scopes it. */
|
|
149
|
+
export interface DuxtVersionLocale {
|
|
150
|
+
/** The locale code, as the site declares it. */
|
|
151
|
+
code: string;
|
|
152
|
+
/** The public prefix for this version in this language. */
|
|
153
|
+
prefix: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One edition of the documentation, as an agent chooses between them. */
|
|
157
|
+
export interface DuxtDocVersion {
|
|
158
|
+
/** The public prefix that scopes it — what `list_pages` takes. */
|
|
159
|
+
prefix: string;
|
|
160
|
+
/** Shown in the switcher: the version label, or the source's segment. */
|
|
161
|
+
label: string;
|
|
162
|
+
/** Where this version sits in its life. */
|
|
163
|
+
status: DuxtResolvedSource['status'];
|
|
164
|
+
/** True for the edition served without a version prefix. */
|
|
165
|
+
isDefault: boolean;
|
|
166
|
+
/** The repository the pages were read from. */
|
|
167
|
+
repository?: string;
|
|
168
|
+
/** The ref they were read at, and which namespace it lives in. */
|
|
169
|
+
ref?: string;
|
|
170
|
+
refKind?: 'branch' | 'tag';
|
|
171
|
+
/** The folder inside that repository. */
|
|
172
|
+
path: string;
|
|
173
|
+
/** Every language it is genuinely published in. */
|
|
174
|
+
locales: DuxtVersionLocale[];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Is this version a translation into `code`, or only reachable from it?
|
|
179
|
+
*
|
|
180
|
+
* The chain's head is the collection that answers a request in `code`. Where
|
|
181
|
+
* that head is the code itself or its language, the version is translated;
|
|
182
|
+
* where the chain fell through to a sibling region or the original, it is not
|
|
183
|
+
* — `en-US` reading `en-GB` through the fallback is not a US translation, and
|
|
184
|
+
* listing it as one tells an agent a page exists that nobody wrote.
|
|
185
|
+
*/
|
|
186
|
+
function translatedInto(
|
|
187
|
+
code: string,
|
|
188
|
+
available: (string | undefined)[],
|
|
189
|
+
fallbackLocale: string | string[] | undefined
|
|
190
|
+
): boolean {
|
|
191
|
+
const head = localeChain(code, available, fallbackLocale)[0];
|
|
192
|
+
|
|
193
|
+
return head === code || head === code.split('-')[0];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Every documentation version this site publishes.
|
|
198
|
+
*
|
|
199
|
+
* Grouped by PREFIX, because that is what identifies an edition: the languages
|
|
200
|
+
* of one version share it, and two versions never do. Generated sections are
|
|
201
|
+
* left out — a changelog or an API reference is a part of a version, not a
|
|
202
|
+
* version of its own, and it is reached by scoping to the version that owns it.
|
|
203
|
+
*/
|
|
204
|
+
export function duxtDocVersions(
|
|
205
|
+
sources: DuxtResolvedSource[],
|
|
206
|
+
setup: DuxtLocaleSetup
|
|
207
|
+
): DuxtDocVersion[] {
|
|
208
|
+
const editions = new Map<string, DuxtResolvedSource[]>();
|
|
209
|
+
|
|
210
|
+
for (const source of sources) {
|
|
211
|
+
if (source.generated) continue;
|
|
212
|
+
|
|
213
|
+
const group = editions.get(source.prefix);
|
|
214
|
+
if (group) group.push(source);
|
|
215
|
+
else editions.set(source.prefix, [source]);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return [...editions.entries()]
|
|
219
|
+
.map(([prefix, group]) => {
|
|
220
|
+
const lead = group.find((source) => source.isDefaultLocale) ?? group[0]!;
|
|
221
|
+
const available = group.map((source) => source.locale);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
prefix: duxtPublicPath(prefix, undefined, setup),
|
|
225
|
+
label: lead.version ?? lead.repo ?? 'default',
|
|
226
|
+
status: lead.status,
|
|
227
|
+
isDefault: lead.isDefault,
|
|
228
|
+
repository: lead.repository,
|
|
229
|
+
ref: lead.ref,
|
|
230
|
+
refKind: lead.refKind,
|
|
231
|
+
path: lead.path,
|
|
232
|
+
locales: setup.codes
|
|
233
|
+
.filter((code) =>
|
|
234
|
+
translatedInto(code, available, setup.fallbackLocale)
|
|
235
|
+
)
|
|
236
|
+
.map((code) => ({
|
|
237
|
+
code,
|
|
238
|
+
prefix: duxtPublicPath(prefix, code, setup)
|
|
239
|
+
}))
|
|
240
|
+
};
|
|
241
|
+
})
|
|
242
|
+
.sort(
|
|
243
|
+
(a, b) =>
|
|
244
|
+
Number(b.isDefault) - Number(a.isDefault) ||
|
|
245
|
+
a.prefix.localeCompare(b.prefix)
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The collections one prefix inside a scope resolves to, in fallback order. */
|
|
250
|
+
interface DuxtScopeGroup {
|
|
251
|
+
prefix: string;
|
|
252
|
+
sources: DuxtResolvedSource[];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A version-and-language scope, resolved from a public URL prefix. */
|
|
256
|
+
export interface DuxtScope {
|
|
257
|
+
/** The prefix as the site serves it, `/` for the whole site. */
|
|
258
|
+
prefix: string;
|
|
259
|
+
/** The locale segment stripped off it, where there was one. */
|
|
260
|
+
locale?: string;
|
|
261
|
+
/** The collections it covers, one entry per source prefix. */
|
|
262
|
+
groups: DuxtScopeGroup[];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** `/app/` and `app` both mean `/app`; `/` and nothing both mean the root. */
|
|
266
|
+
function normalise(prefix: string): string {
|
|
267
|
+
const trimmed = prefix.trim();
|
|
268
|
+
if (!trimmed || trimmed === '/') return '';
|
|
269
|
+
|
|
270
|
+
const rooted = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
271
|
+
|
|
272
|
+
return rooted.replace(/\/+$/, '');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The version prefix a source belongs to: the longest one enclosing it.
|
|
277
|
+
*
|
|
278
|
+
* This is what keeps a nested edition out of its parent's scope. The default
|
|
279
|
+
* version sits at `/app` and the retired one at `/app/v1`, so `/app/v1` is
|
|
280
|
+
* literally inside `/app` — and a scope that took everything inside itself
|
|
281
|
+
* would answer "the current documentation" with two versions of every page.
|
|
282
|
+
* Asking which version prefix is NEAREST puts each source under exactly one.
|
|
283
|
+
*/
|
|
284
|
+
function editionOf(source: DuxtResolvedSource, editions: string[]): string {
|
|
285
|
+
const enclosing = editions.filter((prefix) =>
|
|
286
|
+
isInside(source.prefix, prefix)
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// A generated section declared above every documentation prefix belongs to
|
|
290
|
+
// no edition; it answers for itself rather than joining someone else's.
|
|
291
|
+
return enclosing.length
|
|
292
|
+
? enclosing.reduce((longest, prefix) =>
|
|
293
|
+
prefix.length > longest.length ? prefix : longest
|
|
294
|
+
)
|
|
295
|
+
: source.prefix;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Which collections a public prefix scopes to.
|
|
300
|
+
*
|
|
301
|
+
* `undefined` for a prefix nothing serves — an empty list would read as "this
|
|
302
|
+
* version has no pages", and an agent that mistyped a prefix has to be told to
|
|
303
|
+
* ask `list_versions` instead. Omitting the prefix is NOT the same as scoping
|
|
304
|
+
* to `/`: no scope means every version, while `/` is the edition served at the
|
|
305
|
+
* site root.
|
|
306
|
+
*/
|
|
307
|
+
export function duxtScope(
|
|
308
|
+
prefix: string | undefined,
|
|
309
|
+
sources: DuxtResolvedSource[],
|
|
310
|
+
setup: DuxtLocaleSetup
|
|
311
|
+
): DuxtScope | undefined {
|
|
312
|
+
const asked = prefix === undefined ? undefined : normalise(prefix);
|
|
313
|
+
const { locale, path } =
|
|
314
|
+
asked === undefined
|
|
315
|
+
? { locale: undefined, path: '' }
|
|
316
|
+
: splitLocalePath(asked, setup.codes);
|
|
317
|
+
const wanted = path === '/' ? '' : path;
|
|
318
|
+
|
|
319
|
+
const editions = [
|
|
320
|
+
...new Set(
|
|
321
|
+
sources
|
|
322
|
+
.filter((source) => !source.generated)
|
|
323
|
+
.map((source) => source.prefix)
|
|
324
|
+
)
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
// A version prefix takes the sources of that edition — its own pages and the
|
|
328
|
+
// sections generated beside them. Anything else an agent might paste in (a
|
|
329
|
+
// generated section addressed directly) falls back to what sits inside it.
|
|
330
|
+
const edition = sources.filter(
|
|
331
|
+
(source) => editionOf(source, editions) === wanted
|
|
332
|
+
);
|
|
333
|
+
const selected =
|
|
334
|
+
asked === undefined
|
|
335
|
+
? sources
|
|
336
|
+
: edition.length
|
|
337
|
+
? edition
|
|
338
|
+
: sources.filter((source) => isInside(source.prefix, wanted));
|
|
339
|
+
|
|
340
|
+
if (!selected.length) return undefined;
|
|
341
|
+
|
|
342
|
+
const prefixes = [...new Set(selected.map((source) => source.prefix))].sort();
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
prefix: duxtPublicPath(wanted, locale, setup),
|
|
346
|
+
locale,
|
|
347
|
+
groups: prefixes.map((group) => ({
|
|
348
|
+
prefix: group,
|
|
349
|
+
sources: sourcesForRoute(
|
|
350
|
+
group || '/',
|
|
351
|
+
locale ?? setup.defaultLocale,
|
|
352
|
+
sources,
|
|
353
|
+
setup.fallbackLocale
|
|
354
|
+
)
|
|
355
|
+
}))
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** One page of a scope, at the URL the scope serves it from. */
|
|
360
|
+
export interface DuxtScopedPage {
|
|
361
|
+
/** The public URL path, locale segment included. */
|
|
362
|
+
path: string;
|
|
363
|
+
/** The path the collection stores it under. */
|
|
364
|
+
documentPath: string;
|
|
365
|
+
title?: string;
|
|
366
|
+
description?: string;
|
|
367
|
+
rawbody?: string;
|
|
368
|
+
/**
|
|
369
|
+
* `false` when the page opted out of search.
|
|
370
|
+
*
|
|
371
|
+
* Carried on every scoped page and applied by `search_docs` alone, because
|
|
372
|
+
* the decided rule is that `search: false` makes a page un-findable rather
|
|
373
|
+
* than unpublished. `list_pages` still enumerates it — that listing is the
|
|
374
|
+
* tree, which is the thing `navigation: false` governs — and `read_page`
|
|
375
|
+
* still answers for it at its unchanged URL.
|
|
376
|
+
*/
|
|
377
|
+
search?: boolean;
|
|
378
|
+
/** The source that answered — which language the text is actually in. */
|
|
379
|
+
source: DuxtResolvedSource;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Every page in a scope, translations resolved.
|
|
384
|
+
*
|
|
385
|
+
* One pass per collection rather than one query per page: the chain is short
|
|
386
|
+
* and the first collection holding a path wins, which is the same fallback the
|
|
387
|
+
* rendered page takes. `rawbody` is only asked for when the caller needs it —
|
|
388
|
+
* a table of contents that loads every body is the cost `llms.txt` already had
|
|
389
|
+
* to have taken out of it.
|
|
390
|
+
*/
|
|
391
|
+
export async function duxtScopedPages(
|
|
392
|
+
event: H3Event,
|
|
393
|
+
scope: DuxtScope,
|
|
394
|
+
setup: DuxtLocaleSetup,
|
|
395
|
+
withBody = false
|
|
396
|
+
): Promise<DuxtScopedPage[]> {
|
|
397
|
+
const fields = withBody
|
|
398
|
+
? (['path', 'title', 'description', 'rawbody', 'search'] as const)
|
|
399
|
+
: (['path', 'title', 'description', 'search'] as const);
|
|
400
|
+
const found = new Map<string, DuxtScopedPage>();
|
|
401
|
+
|
|
402
|
+
for (const group of scope.groups) {
|
|
403
|
+
for (const source of group.sources) {
|
|
404
|
+
const pages = (await queryCollection(
|
|
405
|
+
event,
|
|
406
|
+
source.collection as Parameters<typeof queryCollection>[1]
|
|
407
|
+
)
|
|
408
|
+
.select(...fields)
|
|
409
|
+
.all()) as unknown as {
|
|
410
|
+
path?: string;
|
|
411
|
+
title?: string;
|
|
412
|
+
description?: string;
|
|
413
|
+
rawbody?: string;
|
|
414
|
+
search?: boolean;
|
|
415
|
+
}[];
|
|
416
|
+
|
|
417
|
+
for (const page of pages) {
|
|
418
|
+
const documentPath = page.path;
|
|
419
|
+
if (!documentPath || found.has(documentPath)) continue;
|
|
420
|
+
|
|
421
|
+
found.set(documentPath, {
|
|
422
|
+
...page,
|
|
423
|
+
documentPath,
|
|
424
|
+
path: duxtPublicPath(documentPath, scope.locale, setup),
|
|
425
|
+
source
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return [...found.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** How many results a listing returns when the caller does not say. */
|
|
435
|
+
export const DUXT_PAGE_SIZE = 20;
|
|
436
|
+
/** The ceiling, so one call cannot return the whole site. */
|
|
437
|
+
export const DUXT_PAGE_MAX = 100;
|
|
438
|
+
|
|
439
|
+
/** A bounded slice of a listing, and where to resume it. */
|
|
440
|
+
export interface DuxtCursorPage<T> {
|
|
441
|
+
items: T[];
|
|
442
|
+
nextCursor?: string;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Page a listing by its LAST PATH rather than by an offset.
|
|
447
|
+
*
|
|
448
|
+
* An offset silently skips or repeats a page whenever the documentation
|
|
449
|
+
* changes between two calls, and an agent has no way to notice. A path is
|
|
450
|
+
* stable under the same sort, needs no server-side state, and is readable in a
|
|
451
|
+
* transcript — so it stays the path itself rather than an encoded blob, which
|
|
452
|
+
* also keeps it portable to a runtime without `Buffer`.
|
|
453
|
+
*/
|
|
454
|
+
export function duxtCursorPage<T extends { path: string }>(
|
|
455
|
+
items: T[],
|
|
456
|
+
limit: number,
|
|
457
|
+
cursor?: string
|
|
458
|
+
): DuxtCursorPage<T> {
|
|
459
|
+
const size = Math.min(
|
|
460
|
+
DUXT_PAGE_MAX,
|
|
461
|
+
Math.max(1, Math.trunc(limit || DUXT_PAGE_SIZE))
|
|
462
|
+
);
|
|
463
|
+
const after = cursor
|
|
464
|
+
? items.filter((item) => item.path.localeCompare(cursor) > 0)
|
|
465
|
+
: items;
|
|
466
|
+
const page = after.slice(0, size);
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
items: page,
|
|
470
|
+
nextCursor: after.length > page.length ? page.at(-1)?.path : undefined
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** A cursor this module could have issued — anything else is a mistake. */
|
|
475
|
+
export const duxtValidCursor = (cursor: string) => cursor.startsWith('/');
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* A bounded window of the authored Markdown around the first match.
|
|
479
|
+
*
|
|
480
|
+
* Plain text, never the rendered body: a model asked to judge whether a page
|
|
481
|
+
* is the right one wants the sentence the term sits in, and an AST of MDC
|
|
482
|
+
* nodes is neither shorter nor clearer than the page itself. Whitespace is
|
|
483
|
+
* collapsed so a code fence or a table does not spend the whole window on
|
|
484
|
+
* indentation.
|
|
485
|
+
*/
|
|
486
|
+
export function duxtExcerpt(
|
|
487
|
+
body: string | undefined,
|
|
488
|
+
query: string,
|
|
489
|
+
radius = 90
|
|
490
|
+
): string | undefined {
|
|
491
|
+
const text = stripFrontmatter(body ?? '')
|
|
492
|
+
.replace(/\s+/g, ' ')
|
|
493
|
+
.trim();
|
|
494
|
+
if (!text) return undefined;
|
|
495
|
+
|
|
496
|
+
const at = text.toLowerCase().indexOf(query.toLowerCase());
|
|
497
|
+
// A match in the title or the description only: the opening lines are the
|
|
498
|
+
// best answer to "what is this page", which is the question being asked.
|
|
499
|
+
const start = at < 0 ? 0 : Math.max(0, at - radius);
|
|
500
|
+
const end =
|
|
501
|
+
at < 0
|
|
502
|
+
? Math.min(text.length, radius * 2)
|
|
503
|
+
: Math.min(text.length, at + query.length + radius);
|
|
504
|
+
|
|
505
|
+
return `${start > 0 ? '…' : ''}${text.slice(start, end)}${
|
|
506
|
+
end < text.length ? '…' : ''
|
|
507
|
+
}`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* A YAML block duxt writes, over the Markdown the author wrote.
|
|
512
|
+
*
|
|
513
|
+
* Every value is quoted rather than quoted-when-necessary: a version label, a
|
|
514
|
+
* description or a repository name may carry a colon, and an unquoted value
|
|
515
|
+
* with one ends the mapping there — the exact silent failure
|
|
516
|
+
* `tests/frontmatter-yaml.test.ts` exists to catch in this repository's own
|
|
517
|
+
* pages. Undefined keys are dropped; a key present with no value says
|
|
518
|
+
* something the manifest never said.
|
|
519
|
+
*/
|
|
520
|
+
export function duxtFrontmatter(
|
|
521
|
+
fields: Record<string, string | boolean | undefined>
|
|
522
|
+
): string {
|
|
523
|
+
const lines = Object.entries(fields)
|
|
524
|
+
.filter(([, value]) => value !== undefined && value !== '')
|
|
525
|
+
.map(([key, value]) =>
|
|
526
|
+
typeof value === 'boolean'
|
|
527
|
+
? `${key}: ${value}`
|
|
528
|
+
: `${key}: "${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
return `---\n${lines.join('\n')}\n---`;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** The body an agent reads: the author's text, without the author's YAML. */
|
|
535
|
+
export const duxtAuthoredMarkdown = (body: string | undefined) =>
|
|
536
|
+
stripFrontmatter(body ?? '').trim();
|
|
537
|
+
|
|
538
|
+
/** One line of a listing, the shape every tool prints a page in. */
|
|
539
|
+
export const duxtPageLine = (page: {
|
|
540
|
+
path: string;
|
|
541
|
+
title?: string;
|
|
542
|
+
description?: string;
|
|
543
|
+
}) =>
|
|
544
|
+
`- ${page.path} — ${page.title ?? page.path}${
|
|
545
|
+
page.description ? `: ${page.description}` : ''
|
|
546
|
+
}`;
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* What a listing says when a scope does not exist.
|
|
550
|
+
*
|
|
551
|
+
* An error rather than an empty page, and it names the tool that answers the
|
|
552
|
+
* question — an agent handed `[]` reads "this version is empty" and stops.
|
|
553
|
+
*/
|
|
554
|
+
export const duxtUnknownScope = (prefix: string) => ({
|
|
555
|
+
content: [
|
|
556
|
+
{
|
|
557
|
+
type: 'text' as const,
|
|
558
|
+
text:
|
|
559
|
+
`No documentation is served at ${prefix}. ` +
|
|
560
|
+
'Call `list_versions` for the prefixes this site publishes.'
|
|
561
|
+
}
|
|
562
|
+
],
|
|
563
|
+
isError: true
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
/** What a listing says when handed a cursor it cannot have issued. */
|
|
567
|
+
export const duxtBadCursor = (cursor: string) => ({
|
|
568
|
+
content: [
|
|
569
|
+
{
|
|
570
|
+
type: 'text' as const,
|
|
571
|
+
text:
|
|
572
|
+
`"${cursor}" is not a cursor from this server. ` +
|
|
573
|
+
'Pass the `nextCursor` of the previous call, or omit it to start over.'
|
|
574
|
+
}
|
|
575
|
+
],
|
|
576
|
+
isError: true
|
|
577
|
+
});
|