@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
package/app/utils/changelog.ts
CHANGED
|
@@ -152,31 +152,3 @@ export function changelogTone(name: string): DuxtChangelogTone {
|
|
|
152
152
|
|
|
153
153
|
return TONES[hash % TONES.length]!;
|
|
154
154
|
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* A release day, in the reader's language.
|
|
158
|
-
*
|
|
159
|
-
* `timeZone: 'UTC'` because the value is a calendar date and not a moment: read
|
|
160
|
-
* as local time, `2026-09-08` is the 7th for every reader west of Greenwich —
|
|
161
|
-
* and a different day on the server than in the browser, which is a hydration
|
|
162
|
-
* mismatch as well as a wrong date.
|
|
163
|
-
*
|
|
164
|
-
* Here rather than beside one of its callers because TWO of them draw it — the
|
|
165
|
-
* timeline on the overview and the meta row on the release page — and a date
|
|
166
|
-
* formatted in two places is two chances to print two different days for one
|
|
167
|
-
* release.
|
|
168
|
-
*/
|
|
169
|
-
export function changelogDate(
|
|
170
|
-
value: string | undefined,
|
|
171
|
-
locale: string
|
|
172
|
-
): string | undefined {
|
|
173
|
-
if (!value) return undefined;
|
|
174
|
-
|
|
175
|
-
const date = new Date(value);
|
|
176
|
-
if (Number.isNaN(date.getTime())) return undefined;
|
|
177
|
-
|
|
178
|
-
return new Intl.DateTimeFormat(locale, {
|
|
179
|
-
dateStyle: 'medium',
|
|
180
|
-
timeZone: 'UTC'
|
|
181
|
-
}).format(date);
|
|
182
|
-
}
|
|
@@ -52,9 +52,11 @@ const aliases: Record<string, string> = {
|
|
|
52
52
|
curl: 'bash',
|
|
53
53
|
js: 'typescript',
|
|
54
54
|
javascript: 'typescript',
|
|
55
|
+
md: 'markdown',
|
|
55
56
|
sh: 'bash',
|
|
56
57
|
shell: 'bash',
|
|
57
|
-
ts: 'typescript'
|
|
58
|
+
ts: 'typescript',
|
|
59
|
+
yml: 'yaml'
|
|
58
60
|
};
|
|
59
61
|
|
|
60
62
|
let highlighter: Promise<Highlighter> | undefined;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The picture beside a contributor's name, from the template a site configured.
|
|
3
|
+
*
|
|
4
|
+
* Here rather than beside either caller because TWO PLACES draw the same
|
|
5
|
+
* people — the meta column of a page (`DuxtPageInfo`) and the release history
|
|
6
|
+
* (`ChangelogReleases`) — and a template interpolated twice is two chances to
|
|
7
|
+
* show a face on one surface and a letter on the other.
|
|
8
|
+
*
|
|
9
|
+
* `duxt.contributors.avatarUrl` holds the whole URL with `{username}` in it, so
|
|
10
|
+
* a site pointing at a different forge, an avatar proxy or nothing at all is a
|
|
11
|
+
* config change rather than a component fork. UNDEFINED WHERE THERE IS NO
|
|
12
|
+
* HANDLE, which is the honest answer and not a placeholder: git only carries a
|
|
13
|
+
* username in a noreply address, and a guessed avatar is a picture of somebody
|
|
14
|
+
* else.
|
|
15
|
+
*
|
|
16
|
+
* A plain replacement, deliberately not an encoding pass: every caller here
|
|
17
|
+
* takes its username from `githubUsername`, which reads it out of an address
|
|
18
|
+
* GitHub itself issued.
|
|
19
|
+
*/
|
|
20
|
+
export function contributorAvatar(
|
|
21
|
+
template: string | undefined,
|
|
22
|
+
username: string | undefined
|
|
23
|
+
): string | undefined {
|
|
24
|
+
if (!template || !username) return undefined;
|
|
25
|
+
|
|
26
|
+
return template.replace('{username}', username);
|
|
27
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which way round the interface is drawn.
|
|
3
|
+
*
|
|
4
|
+
* `auto` is deliberately absent. i18n lets a locale declare it, but it is a
|
|
5
|
+
* per-element CONTENT heuristic — the browser guessing at a run of text — and
|
|
6
|
+
* not a layout direction. Chrome cannot be mirrored on "it depends", so the
|
|
7
|
+
* resolver below collapses it to `ltr` rather than carrying a third state that
|
|
8
|
+
* nothing downstream could act on.
|
|
9
|
+
*/
|
|
10
|
+
export type DuxtDirection = 'ltr' | 'rtl';
|
|
11
|
+
|
|
12
|
+
/** The shape i18n hands back from `locales`: objects, or bare codes. */
|
|
13
|
+
export type DuxtLocaleEntry =
|
|
14
|
+
| string
|
|
15
|
+
| { code: string; dir?: 'ltr' | 'rtl' | 'auto' };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The direction the active locale declares, or left-to-right.
|
|
19
|
+
*
|
|
20
|
+
* THE DECLARATION IS THE ONLY SOURCE. The layer does not keep a list of
|
|
21
|
+
* right-to-left languages and does not guess from a script or a region: a
|
|
22
|
+
* locale is right-to-left because its entry says `dir: 'rtl'`, which is the
|
|
23
|
+
* one place a consumer adding a language can state it. Guessing would mean a
|
|
24
|
+
* consumer's own locale silently rendering mirrored — or, worse, silently not.
|
|
25
|
+
*
|
|
26
|
+
* Shared by `app.vue` and `error.vue` on purpose. The error page REPLACES the
|
|
27
|
+
* app shell rather than nesting inside it, so a direction computed inline in
|
|
28
|
+
* one of them is a direction the other does not have — which is precisely how
|
|
29
|
+
* `error.vue` came to set `lang` and never `dir`.
|
|
30
|
+
*/
|
|
31
|
+
export function localeDirection(
|
|
32
|
+
locales: readonly DuxtLocaleEntry[],
|
|
33
|
+
active: string
|
|
34
|
+
): DuxtDirection {
|
|
35
|
+
const entry = locales.find(
|
|
36
|
+
(candidate) =>
|
|
37
|
+
(typeof candidate === 'string' ? candidate : candidate.code) === active
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
if (typeof entry !== 'object' || !entry) return 'ltr';
|
|
41
|
+
|
|
42
|
+
return entry.dir === 'rtl' ? 'rtl' : 'ltr';
|
|
43
|
+
}
|
package/app/utils/duxt-config.ts
CHANGED
|
@@ -19,12 +19,55 @@ export const duxtDefaults: DuxtConfig = {
|
|
|
19
19
|
// until a consumer sets its own. It names nobody, which is the whole test.
|
|
20
20
|
title: 'duxt.defaults.title',
|
|
21
21
|
|
|
22
|
+
copy: {
|
|
23
|
+
models: [
|
|
24
|
+
{
|
|
25
|
+
label: 'duxt.page.copy.chatgpt',
|
|
26
|
+
icon: 'simple-icons:openai',
|
|
27
|
+
url: 'https://chatgpt.com/?q='
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
label: 'duxt.page.copy.claude',
|
|
31
|
+
icon: 'simple-icons:claude',
|
|
32
|
+
url: 'https://claude.ai/new?q='
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
contributors: { avatarUrl: 'https://github.com/{username}.png?size=40' },
|
|
37
|
+
toc: { depth: 3, scrollOffset: 96 },
|
|
38
|
+
search: {
|
|
39
|
+
fuzzy: { threshold: 0.35, minMatchCharLength: 3, limit: 20 },
|
|
40
|
+
recentPages: 5
|
|
41
|
+
},
|
|
42
|
+
openapi: { exampleDepth: 6, schemaDepth: 8 },
|
|
43
|
+
// A policy, not a binding table. WHICH keys duxt listens to is the layer's
|
|
44
|
+
// and lives in `duxtShortcuts`; whether the unmodified ones are live at all
|
|
45
|
+
// is the site's, because only a site knows what else its pages put under a
|
|
46
|
+
// bare keystroke. One boolean rather than four rewritable rows: a consumer
|
|
47
|
+
// that could move `?` would have to move it in the handler, the sheet and
|
|
48
|
+
// the guards at once, and those three drifting apart is what this replaces.
|
|
49
|
+
shortcuts: { singleCharacter: true },
|
|
50
|
+
|
|
22
51
|
// No `version`. The layer knows nothing about the state of somebody else's
|
|
23
52
|
// project, and a default made every site that extended it wear a number it
|
|
24
53
|
// had never set — in the hero pill and, for a site with at most one source,
|
|
25
54
|
// in the header badge beside the search. A site that has a version says so
|
|
26
55
|
// in its own `app.config.ts`; `DuxtVersion` draws nothing until it does.
|
|
27
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Empty, like `sections` and `links` below.
|
|
59
|
+
*
|
|
60
|
+
* An announcement is the SITE's own sentence — a release, a migration window,
|
|
61
|
+
* an outage — and a layer that shipped one would put its words at the top of
|
|
62
|
+
* every page extending it. Left empty the strip draws nothing at all: no
|
|
63
|
+
* banner, and no space held open for one.
|
|
64
|
+
*
|
|
65
|
+
* Written out rather than left absent so the key appears in the devtools
|
|
66
|
+
* Config panel, where a consumer meets it without reading the reference
|
|
67
|
+
* first — the same reason `sections` and `links` are spelled out below.
|
|
68
|
+
*/
|
|
69
|
+
announcements: [],
|
|
70
|
+
|
|
28
71
|
navigation: [
|
|
29
72
|
// No `to`: the header resolves it to the first section, so the entry works
|
|
30
73
|
// whether or not the consumer's URLs carry a prefix.
|
package/app/utils/duxt-text.ts
CHANGED
|
@@ -38,6 +38,16 @@ const TEXT_KEYS = new Set([
|
|
|
38
38
|
// JSON on the page, which is exactly how this one was found.
|
|
39
39
|
'highlightsTitle',
|
|
40
40
|
'label',
|
|
41
|
+
// A NAME, never an address. A source's display name and
|
|
42
|
+
// `duxt.organization.name` are both prose a site may translate; the strings
|
|
43
|
+
// beside them a reader might mistake for names — `collection`, `prefix`,
|
|
44
|
+
// `repo`, `slug` — are absent from this list and stay untouched, which is
|
|
45
|
+
// the whole reason it is an allowlist rather than a walk.
|
|
46
|
+
'name',
|
|
47
|
+
// An announcement's sentence. Neither `label` nor `description`, because it
|
|
48
|
+
// is the whole of what the banner says rather than a caption on something
|
|
49
|
+
// else — and it needs the line here for the same reason the entry above does.
|
|
50
|
+
'text',
|
|
41
51
|
'title'
|
|
42
52
|
]);
|
|
43
53
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A date, in the reader's language — or nothing, where there is no readable one.
|
|
3
|
+
*
|
|
4
|
+
* One formatter for every date the theme prints, because a date formatted in
|
|
5
|
+
* two places is two chances to print two different days for one fact.
|
|
6
|
+
*
|
|
7
|
+
* `calendarDay` is the one decision a caller makes, and it is about what the
|
|
8
|
+
* value IS. A release date is a calendar day and not a moment: read as local
|
|
9
|
+
* time, `2026-09-08` is the 7th for every reader west of Greenwich — and a
|
|
10
|
+
* different day on the server than in the browser, which is a hydration
|
|
11
|
+
* mismatch as well as a wrong date — so it is pinned to UTC. A commit date IS a
|
|
12
|
+
* moment, and is left in the reader's own zone.
|
|
13
|
+
*/
|
|
14
|
+
export function formatDate(
|
|
15
|
+
value: string | undefined,
|
|
16
|
+
locale: string,
|
|
17
|
+
{ calendarDay = false }: { calendarDay?: boolean } = {}
|
|
18
|
+
): string | undefined {
|
|
19
|
+
if (!value) return undefined;
|
|
20
|
+
|
|
21
|
+
const date = new Date(value);
|
|
22
|
+
if (Number.isNaN(date.getTime())) return undefined;
|
|
23
|
+
|
|
24
|
+
return new Intl.DateTimeFormat(locale, {
|
|
25
|
+
dateStyle: 'medium',
|
|
26
|
+
...(calendarDay ? { timeZone: 'UTC' } : {})
|
|
27
|
+
}).format(date);
|
|
28
|
+
}
|
|
@@ -65,6 +65,7 @@ export function withGeneratedSections(
|
|
|
65
65
|
// answered for the consumer's list only.
|
|
66
66
|
const taken = new Set(existing.map((entry) => entry.to).filter(Boolean));
|
|
67
67
|
const entries: DuxtLink[] = [];
|
|
68
|
+
const listed: DuxtResolvedSource[][] = [];
|
|
68
69
|
|
|
69
70
|
for (const declaration of declarations) {
|
|
70
71
|
const meta = declaration[0]!.generated!;
|
|
@@ -74,7 +75,10 @@ export function withGeneratedSections(
|
|
|
74
75
|
// the one this reader would be given: a section listed by hand is listed,
|
|
75
76
|
// and the entry `to` resolves to is always one of these prefixes, so this
|
|
76
77
|
// is the de-dupe by URL as well.
|
|
77
|
-
if (declaration.some((entry) => taken.has(entry.prefix)))
|
|
78
|
+
if (declaration.some((entry) => taken.has(entry.prefix))) {
|
|
79
|
+
listed.push(declaration);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
78
82
|
|
|
79
83
|
const to = entryPath(declaration, base);
|
|
80
84
|
|
|
@@ -82,27 +86,79 @@ export function withGeneratedSections(
|
|
|
82
86
|
entries.push({ label: meta.label, to, icon: meta.icon });
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
return entries;
|
|
89
|
+
return { entries, moved: followReader(existing, listed, base) };
|
|
86
90
|
};
|
|
87
91
|
|
|
88
92
|
const navigation = entriesFor('navigation', config.navigation ?? []);
|
|
89
93
|
const sections = entriesFor('sections', config.sections ?? []);
|
|
90
94
|
|
|
91
|
-
if (
|
|
95
|
+
if (
|
|
96
|
+
!navigation.entries.length &&
|
|
97
|
+
!sections.entries.length &&
|
|
98
|
+
!navigation.moved &&
|
|
99
|
+
!sections.moved
|
|
100
|
+
)
|
|
101
|
+
return config;
|
|
92
102
|
|
|
93
103
|
return {
|
|
94
104
|
...config,
|
|
95
|
-
// Only the row that gains something is rewritten: an empty array
|
|
96
|
-
// config had `undefined` is a different value, and the header
|
|
97
|
-
|
|
98
|
-
|
|
105
|
+
// Only the row that gains or moves something is rewritten: an empty array
|
|
106
|
+
// where the config had `undefined` is a different value, and the header
|
|
107
|
+
// reads both.
|
|
108
|
+
...(navigation.entries.length || navigation.moved
|
|
109
|
+
? {
|
|
110
|
+
navigation: [
|
|
111
|
+
...(navigation.moved ?? config.navigation ?? []),
|
|
112
|
+
...navigation.entries
|
|
113
|
+
]
|
|
114
|
+
}
|
|
99
115
|
: {}),
|
|
100
|
-
...(sections.length
|
|
101
|
-
? {
|
|
116
|
+
...(sections.entries.length || sections.moved
|
|
117
|
+
? {
|
|
118
|
+
sections: [
|
|
119
|
+
...(sections.moved ?? config.sections ?? []),
|
|
120
|
+
...sections.entries
|
|
121
|
+
]
|
|
122
|
+
}
|
|
102
123
|
: {})
|
|
103
124
|
};
|
|
104
125
|
}
|
|
105
126
|
|
|
127
|
+
/**
|
|
128
|
+
* A section listed by hand keeps its POSITION, and its URL follows the reader.
|
|
129
|
+
*
|
|
130
|
+
* Placing an entry by hand is how a consumer orders the row, and a `per-version`
|
|
131
|
+
* section written at the default version's URL would otherwise send a reader on
|
|
132
|
+
* `v2.x` to `v3.x` the moment they clicked it. So an entry whose `to` is one of
|
|
133
|
+
* the declaration's own prefixes is pointed at the edition the reader is on —
|
|
134
|
+
* the same answer the appended entry gets. An entry pointing anywhere else
|
|
135
|
+
* inside the section is a page the consumer chose, and stays where it points.
|
|
136
|
+
*
|
|
137
|
+
* `undefined` when nothing moved, so an untouched row keeps its identity.
|
|
138
|
+
*/
|
|
139
|
+
function followReader(
|
|
140
|
+
existing: DuxtLink[],
|
|
141
|
+
listed: DuxtResolvedSource[][],
|
|
142
|
+
base: string
|
|
143
|
+
): DuxtLink[] | undefined {
|
|
144
|
+
let changed = false;
|
|
145
|
+
|
|
146
|
+
const moved = existing.map((entry) => {
|
|
147
|
+
const declaration = listed.find((candidate) =>
|
|
148
|
+
candidate.some((source) => source.prefix === entry.to)
|
|
149
|
+
);
|
|
150
|
+
if (!declaration) return entry;
|
|
151
|
+
|
|
152
|
+
const to = entryPath(declaration, base);
|
|
153
|
+
if (to === entry.to) return entry;
|
|
154
|
+
|
|
155
|
+
changed = true;
|
|
156
|
+
return { ...entry, to };
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return changed ? moved : undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
106
162
|
/**
|
|
107
163
|
* The manifest's generated entries, grouped into the declarations that made
|
|
108
164
|
* them, in the order the site declared them.
|
|
@@ -171,11 +227,11 @@ const depth = (prefix: string) => (prefix ? prefix.split('/').length - 1 : 0);
|
|
|
171
227
|
*
|
|
172
228
|
* THE SHARED SLOT. Each type names its own layout and every type renders
|
|
173
229
|
* through this one lookup, which is what stops two types inventing two
|
|
174
|
-
* unrelated ways to escape the docs
|
|
175
|
-
* the ordinary
|
|
230
|
+
* unrelated ways to escape the docs layout. A type that names none renders in
|
|
231
|
+
* the ordinary docs layout, and the lookup answers `undefined`.
|
|
176
232
|
*
|
|
177
233
|
* Filtered to the sections that name a layout before the prefix match, so a
|
|
178
|
-
* site whose types all render in the docs
|
|
234
|
+
* site whose types all render in the docs layout does no work at all.
|
|
179
235
|
*/
|
|
180
236
|
export function generatedLayout(
|
|
181
237
|
path: string,
|
|
@@ -24,7 +24,7 @@ export interface DuxtGeneratedTocLink {
|
|
|
24
24
|
children?: DuxtGeneratedTocLink[];
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
const HEADING = /^h([
|
|
27
|
+
const HEADING = /^h([2-6])$/;
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* A prop, whichever spelling it arrived in.
|
|
@@ -63,7 +63,11 @@ function text(node: unknown): string {
|
|
|
63
63
|
* promoted to `##` by the parser — so inside a group it is one level further
|
|
64
64
|
* in, or the column would claim both are siblings.
|
|
65
65
|
*/
|
|
66
|
-
function walk(
|
|
66
|
+
function walk(
|
|
67
|
+
nodes: unknown[],
|
|
68
|
+
inside: boolean,
|
|
69
|
+
maximumDepth: number
|
|
70
|
+
): DuxtGeneratedTocLink[] {
|
|
67
71
|
const links: DuxtGeneratedTocLink[] = [];
|
|
68
72
|
|
|
69
73
|
for (const node of nodes) {
|
|
@@ -84,7 +88,7 @@ function walk(nodes: unknown[], inside: boolean): DuxtGeneratedTocLink[] {
|
|
|
84
88
|
// The same words the heading itself draws — the anchor is still built
|
|
85
89
|
// from the file's own name, so the two cannot point apart.
|
|
86
90
|
if (id) links.push({ id, text: changelogLabel(name), depth: 2 });
|
|
87
|
-
links.push(...walk(children, true));
|
|
91
|
+
links.push(...walk(children, true, maximumDepth));
|
|
88
92
|
continue;
|
|
89
93
|
}
|
|
90
94
|
|
|
@@ -96,7 +100,7 @@ function walk(nodes: unknown[], inside: boolean): DuxtGeneratedTocLink[] {
|
|
|
96
100
|
if (tag === 'changelog-releases') continue;
|
|
97
101
|
|
|
98
102
|
const heading = HEADING.exec(tag);
|
|
99
|
-
if (heading) {
|
|
103
|
+
if (heading && Number(heading[1]) <= maximumDepth) {
|
|
100
104
|
const id = props.id;
|
|
101
105
|
if (typeof id === 'string' && id) {
|
|
102
106
|
links.push({
|
|
@@ -108,20 +112,23 @@ function walk(nodes: unknown[], inside: boolean): DuxtGeneratedTocLink[] {
|
|
|
108
112
|
continue;
|
|
109
113
|
}
|
|
110
114
|
|
|
111
|
-
links.push(...walk(children, inside));
|
|
115
|
+
links.push(...walk(children, inside, maximumDepth));
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
return links;
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
/** The flat list nested the way `DuxtToc` draws it: one level of children. */
|
|
118
|
-
export function generatedToc(
|
|
122
|
+
export function generatedToc(
|
|
123
|
+
body: unknown,
|
|
124
|
+
maximumDepth = 3
|
|
125
|
+
): DuxtGeneratedTocLink[] {
|
|
119
126
|
const value = (body as { value?: unknown })?.value;
|
|
120
127
|
if (!Array.isArray(value)) return [];
|
|
121
128
|
|
|
122
129
|
const links: DuxtGeneratedTocLink[] = [];
|
|
123
130
|
|
|
124
|
-
for (const link of walk(value, false)) {
|
|
131
|
+
for (const link of walk(value, false, maximumDepth)) {
|
|
125
132
|
const parent = links.at(-1);
|
|
126
133
|
|
|
127
134
|
if (link.depth > 2 && parent) (parent.children ??= []).push(link);
|
package/app/utils/locale-path.ts
CHANGED
|
@@ -21,6 +21,22 @@ export function stripLocalePrefix(
|
|
|
21
21
|
return documentPath.replace(/\/+$/, '') || (documentPath ? '/' : '');
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The locale CODES out of an i18n locale list.
|
|
26
|
+
*
|
|
27
|
+
* `@nuxtjs/i18n` accepts a bare code or an object carrying one, and every place
|
|
28
|
+
* that strips a prefix — the composable, the layout middleware, the devtools
|
|
29
|
+
* path debugger — needs the plain list. Absent is an empty list: a site without
|
|
30
|
+
* the module has no prefixes to strip.
|
|
31
|
+
*/
|
|
32
|
+
export function localeCodesOf(
|
|
33
|
+
locales: readonly (string | { code: string })[] | undefined
|
|
34
|
+
): string[] {
|
|
35
|
+
return (locales ?? []).map((entry) =>
|
|
36
|
+
typeof entry === 'string' ? entry : entry.code
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
/**
|
|
25
41
|
* The same split, keeping the half that was thrown away.
|
|
26
42
|
*
|
|
@@ -46,3 +62,30 @@ export function splitLocalePath(
|
|
|
46
62
|
|
|
47
63
|
return { path };
|
|
48
64
|
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The routes a MACHINE reads, which exist once for the whole site.
|
|
68
|
+
*
|
|
69
|
+
* `llms.txt`, `llms-full.txt`, `rss.xml` and the MCP server are server routes
|
|
70
|
+
* at the root. They are not pages, `@nuxtjs/i18n` knows nothing of them, and
|
|
71
|
+
* `localePath('/llms.txt')` therefore hands back `/de-DE/llms.txt` — a path
|
|
72
|
+
* that falls through to the catch-all page and 404s. That is what the landing
|
|
73
|
+
* page's framed `llms.txt` did in every locale: Nitro's error response carries
|
|
74
|
+
* `X-Frame-Options: DENY`, so the reader saw a broken frame rather than a 404.
|
|
75
|
+
*
|
|
76
|
+
* An explicit list, not "has a file extension": a version segment such as
|
|
77
|
+
* `v0.1.0` reads as one. And `…/page.md` is deliberately NOT on it — the
|
|
78
|
+
* raw-markdown middleware splits the locale off and serves the translation,
|
|
79
|
+
* so a localised `.md` link is the right link.
|
|
80
|
+
*
|
|
81
|
+
* Mirrors the root rows of `DEPLOYMENT_ROUTES` in `scripts/check-routes.ts`.
|
|
82
|
+
*/
|
|
83
|
+
const MACHINE_ROUTES = ['/llms.txt', '/llms-full.txt', '/rss.xml', '/mcp'];
|
|
84
|
+
|
|
85
|
+
export function isMachineRoute(path: string): boolean {
|
|
86
|
+
const bare = path.split(/[?#]/)[0]!.replace(/\/+$/, '');
|
|
87
|
+
|
|
88
|
+
return MACHINE_ROUTES.some(
|
|
89
|
+
(route) => bare === route || (route === '/mcp' && bare.startsWith('/mcp/'))
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import type { ContentNavigationItem } from '@nuxt/content';
|
|
2
2
|
|
|
3
|
+
/** Pages in reading order, with non-page groups expanded into their members. */
|
|
4
|
+
export function flattenedNavigationPages(
|
|
5
|
+
items: ContentNavigationItem[]
|
|
6
|
+
): ContentNavigationItem[] {
|
|
7
|
+
const pages: ContentNavigationItem[] = [];
|
|
8
|
+
|
|
9
|
+
for (const item of items) {
|
|
10
|
+
if (item.page !== false) pages.push(item);
|
|
11
|
+
if (item.children?.length) {
|
|
12
|
+
pages.push(...flattenedNavigationPages(item.children));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return pages;
|
|
17
|
+
}
|
|
18
|
+
|
|
3
19
|
/** Actual pages, including dotted version paths; folder wrappers are not pages. */
|
|
4
20
|
export function navigationPagePaths(items: ContentNavigationItem[]): string[] {
|
|
5
21
|
const paths = new Set<string>();
|
|
@@ -90,6 +106,14 @@ export function sectionItems(
|
|
|
90
106
|
* Anything at or above the source's own prefix is one of those wrapper nodes,
|
|
91
107
|
* not a page anyone navigates to — it showed up as an extra crumb on a
|
|
92
108
|
* versioned URL that the unversioned one did not have.
|
|
109
|
+
*
|
|
110
|
+
* A `page: false` node on the chain is the same thing one level down: a group
|
|
111
|
+
* that exists to hold pages and has no route of its own — tfplugindocs'
|
|
112
|
+
* `subcategory` groups are the standing example, synthesised from frontmatter
|
|
113
|
+
* with no directory behind them. The walk still goes THROUGH such a node to
|
|
114
|
+
* reach the page; only the returned trail drops it, because `DuxtBreadcrumb`
|
|
115
|
+
* links every crumb but the last and `useDuxtBreadcrumb` emits the same trail
|
|
116
|
+
* as `BreadcrumbList` JSON-LD — so a group left in ships a 404 twice over.
|
|
93
117
|
*/
|
|
94
118
|
export function trailBelowPrefix(
|
|
95
119
|
tree: ContentNavigationItem[],
|
|
@@ -118,7 +142,33 @@ export function trailBelowPrefix(
|
|
|
118
142
|
|
|
119
143
|
walk(tree, []);
|
|
120
144
|
|
|
121
|
-
return found.filter(
|
|
145
|
+
return found.filter(
|
|
146
|
+
(item) => item.page !== false && (item.path?.length ?? 0) > prefix.length
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The children a `::page-cards` block draws for a branch.
|
|
152
|
+
*
|
|
153
|
+
* The branch's own index carries the branch's path — Content emits it as a
|
|
154
|
+
* child — and is the page the cards sit on, so it is never a card.
|
|
155
|
+
*
|
|
156
|
+
* A `page: false` group is replaced by its members rather than dropped: the
|
|
157
|
+
* group has no route to link to, but the pages under it are exactly what the
|
|
158
|
+
* reader came for. `flattenedNavigationPages` is not the same thing and cannot
|
|
159
|
+
* stand in — it descends through real folders too, and a card for a folder is
|
|
160
|
+
* the point of a card grid.
|
|
161
|
+
*/
|
|
162
|
+
export function navigationCardItems(
|
|
163
|
+
branch: ContentNavigationItem | undefined,
|
|
164
|
+
base: string
|
|
165
|
+
): ContentNavigationItem[] {
|
|
166
|
+
const expand = (items: ContentNavigationItem[]): ContentNavigationItem[] =>
|
|
167
|
+
items.flatMap((item) =>
|
|
168
|
+
item.page === false ? expand(item.children ?? []) : [item]
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
return expand(branch?.children ?? []).filter((child) => child.path !== base);
|
|
122
172
|
}
|
|
123
173
|
|
|
124
174
|
/**
|
|
@@ -19,8 +19,16 @@ export function nearestPages(
|
|
|
19
19
|
const target = segments(wanted);
|
|
20
20
|
if (!target.length) return [];
|
|
21
21
|
|
|
22
|
+
// ONE ROW PER PATH. A folder's index and the folder itself are both in the
|
|
23
|
+
// navigation under the same route, and the list offered the reader
|
|
24
|
+
// `Concepts /v0.2.0/concepts` twice.
|
|
25
|
+
const seen = new Set<string>();
|
|
26
|
+
|
|
22
27
|
return candidates
|
|
23
28
|
.filter((candidate) => candidate.path && candidate.path !== wanted)
|
|
29
|
+
.filter(
|
|
30
|
+
(candidate) => !seen.has(candidate.path) && seen.add(candidate.path)
|
|
31
|
+
)
|
|
24
32
|
.map((candidate) => ({
|
|
25
33
|
candidate,
|
|
26
34
|
score: score(target, segments(candidate.path))
|
package/app/utils/openapi.ts
CHANGED
|
@@ -21,8 +21,6 @@ import type {
|
|
|
21
21
|
} from '../../openapi-model';
|
|
22
22
|
|
|
23
23
|
/** How deep an example is derived before it is called recursive enough. */
|
|
24
|
-
const MAX_DEPTH = 6;
|
|
25
|
-
|
|
26
24
|
/**
|
|
27
25
|
* A schema's type, as the one word a table cell has room for.
|
|
28
26
|
*
|
|
@@ -111,9 +109,10 @@ export function excluded(
|
|
|
111
109
|
export function openApiExampleValue(
|
|
112
110
|
schema: DuxtOpenApiSchema | undefined,
|
|
113
111
|
direction: DuxtOpenApiDirection,
|
|
114
|
-
depth = 0
|
|
112
|
+
depth = 0,
|
|
113
|
+
maximumDepth = 6
|
|
115
114
|
): unknown {
|
|
116
|
-
if (!schema || schema.circular || depth >
|
|
115
|
+
if (!schema || schema.circular || depth > maximumDepth) return null;
|
|
117
116
|
|
|
118
117
|
if (schema.examples?.length) return schema.examples[0];
|
|
119
118
|
if (schema.default !== undefined) return schema.default;
|
|
@@ -121,7 +120,8 @@ export function openApiExampleValue(
|
|
|
121
120
|
if (schema.enum?.length) return schema.enum[0];
|
|
122
121
|
|
|
123
122
|
const branch = schema.oneOf?.[0] ?? schema.anyOf?.[0];
|
|
124
|
-
if (branch)
|
|
123
|
+
if (branch)
|
|
124
|
+
return openApiExampleValue(branch, direction, depth + 1, maximumDepth);
|
|
125
125
|
|
|
126
126
|
const declared = (schema.types ?? []).filter((type) => type !== 'null');
|
|
127
127
|
const type =
|
|
@@ -149,24 +149,29 @@ export function openApiExampleValue(
|
|
|
149
149
|
built[property.name] = openApiExampleValue(
|
|
150
150
|
property.schema,
|
|
151
151
|
direction,
|
|
152
|
-
depth + 1
|
|
152
|
+
depth + 1,
|
|
153
|
+
maximumDepth
|
|
153
154
|
);
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
for (const merged of schema.allOf ?? []) {
|
|
157
158
|
Object.assign(
|
|
158
159
|
built,
|
|
159
|
-
openApiExampleValue(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
openApiExampleValue(
|
|
161
|
+
merged,
|
|
162
|
+
direction,
|
|
163
|
+
depth + 1,
|
|
164
|
+
maximumDepth
|
|
165
|
+
) as Record<string, unknown>
|
|
163
166
|
);
|
|
164
167
|
}
|
|
165
168
|
|
|
166
169
|
return built;
|
|
167
170
|
}
|
|
168
171
|
case 'array':
|
|
169
|
-
return [
|
|
172
|
+
return [
|
|
173
|
+
openApiExampleValue(schema.items, direction, depth + 1, maximumDepth)
|
|
174
|
+
];
|
|
170
175
|
case 'boolean':
|
|
171
176
|
return true;
|
|
172
177
|
case 'integer':
|