@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
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a PAGE gets to say about the docs shell drawn around it.
|
|
3
|
+
*
|
|
4
|
+
* A page could already say what it is — `title`, `description`, `icon`, `date`
|
|
5
|
+
* — and one thing about where it goes. It could say nothing about the shell:
|
|
6
|
+
* the table of contents, the prev/next pair, the feedback row and the
|
|
7
|
+
* provenance block all rendered unconditionally, and the only escape was
|
|
8
|
+
* `layout: landing`, which drops the whole docs shell and leaves the author
|
|
9
|
+
* rebuilding it by hand. An API reference that wants the full width and a legal
|
|
10
|
+
* page that wants no "was this helpful?" both ended up there.
|
|
11
|
+
*
|
|
12
|
+
* So: a small, flat set of frontmatter fields, each of which turns off one
|
|
13
|
+
* thing the shell draws. Three rules hold the surface down, and all three were
|
|
14
|
+
* decided rather than discovered:
|
|
15
|
+
*
|
|
16
|
+
* - **Flat fields, not one nested object.** `toc: false` is what an author
|
|
17
|
+
* writes in every comparable generator, and gathering them under a single
|
|
18
|
+
* key buys a namespace nothing else needs.
|
|
19
|
+
* - **A page cannot hide the left navigation.** `layout: landing` stays the
|
|
20
|
+
* one no-shell escape; `fullWidth` keeps the header and the sidebar, because
|
|
21
|
+
* a page that removed the sidebar is a page the reader cannot leave.
|
|
22
|
+
* - **The controls compose and never imply one another.** `fullWidth` removes
|
|
23
|
+
* the reading measure and nothing else; `copyPage` is independent of
|
|
24
|
+
* `pageInfo`. A field that silently means three things cannot be combined
|
|
25
|
+
* with the two it swallowed.
|
|
26
|
+
*
|
|
27
|
+
* Every name here is public API the moment it ships — Content drops an
|
|
28
|
+
* undeclared field silently, so `pageSchema` in `sources.ts` is the gate, and
|
|
29
|
+
* `docs/4.reference/3.frontmatter.md` is where an author reads them. Renaming
|
|
30
|
+
* one is a breaking change.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The deepest heading the contents column draws when nobody says otherwise. */
|
|
34
|
+
export const DUXT_TOC_MAX_DEPTH = 3;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The range a page may ask for, as HTML heading levels rather than as a count.
|
|
38
|
+
*
|
|
39
|
+
* `h1` is never in the list — the title is the page's one `h1` — and there is
|
|
40
|
+
* no `h7`. Stated as levels because that is what an author counts in their own
|
|
41
|
+
* document; Content's own `toc.depth` is a count from `h2` and is set once, in
|
|
42
|
+
* the layer's `nuxt.config.ts`, deep enough that every level below is available
|
|
43
|
+
* to be filtered back down to here.
|
|
44
|
+
*/
|
|
45
|
+
const TOC_DEPTHS = [2, 3, 4, 5, 6];
|
|
46
|
+
|
|
47
|
+
/** One heading in the contents column, in the shape `DuxtToc` draws. */
|
|
48
|
+
export interface DuxtTocLink {
|
|
49
|
+
id: string;
|
|
50
|
+
text: string;
|
|
51
|
+
depth: number;
|
|
52
|
+
children?: DuxtTocLink[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The `toc` field's object form: the column, drawn this deep. */
|
|
56
|
+
export interface DuxtTocControl {
|
|
57
|
+
/** The deepest heading level listed. `2`–`6`; the list always starts at h2. */
|
|
58
|
+
maxDepth?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The frontmatter an author writes. Every field is optional, by definition. */
|
|
62
|
+
export interface DuxtPageControlFields {
|
|
63
|
+
/** `false` drops the contents column; an object sets how deep it goes. */
|
|
64
|
+
toc?: boolean | DuxtTocControl;
|
|
65
|
+
/** `false` hides the trail above the title. Overrides `duxt.breadcrumb`. */
|
|
66
|
+
breadcrumb?: boolean;
|
|
67
|
+
/** `false` hides the previous/next pair under the article. */
|
|
68
|
+
prevNext?: boolean;
|
|
69
|
+
/** `false` hides the "was this helpful?" row. */
|
|
70
|
+
feedback?: boolean;
|
|
71
|
+
/** `false` hides the edit link, the last-updated line and the contributors. */
|
|
72
|
+
pageInfo?: boolean;
|
|
73
|
+
/** `false` hides the copy-page and hand-to-a-model control. */
|
|
74
|
+
copyPage?: boolean;
|
|
75
|
+
/** `true` removes the reading-width cap. Nothing else. */
|
|
76
|
+
fullWidth?: boolean;
|
|
77
|
+
/** `false` removes the page from every duxt-owned discovery surface. */
|
|
78
|
+
search?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The same questions, answered — one object the page component reads. */
|
|
82
|
+
export interface DuxtPageControls {
|
|
83
|
+
toc: boolean;
|
|
84
|
+
/** The deepest heading level the column lists. Meaningful only when `toc`. */
|
|
85
|
+
tocMaxDepth: number;
|
|
86
|
+
breadcrumb: boolean;
|
|
87
|
+
prevNext: boolean;
|
|
88
|
+
feedback: boolean;
|
|
89
|
+
pageInfo: boolean;
|
|
90
|
+
copyPage: boolean;
|
|
91
|
+
fullWidth: boolean;
|
|
92
|
+
search: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What the SITE decided, for the two controls that have a site-wide switch. */
|
|
96
|
+
export interface DuxtPageControlDefaults {
|
|
97
|
+
/** `duxt.breadcrumb` — the switch that existed before the per-page one. */
|
|
98
|
+
breadcrumb?: boolean;
|
|
99
|
+
/** `duxt.toc.depth` — the site's own maximum heading level. */
|
|
100
|
+
tocMaxDepth?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A control is only ever turned off by a real `false`.
|
|
105
|
+
*
|
|
106
|
+
* Frontmatter is YAML a human typed, and Content stores what the schema
|
|
107
|
+
* declared without asserting the value is the type it declared — so `toc: "no"`
|
|
108
|
+
* arrives as the string. Anything that is not a boolean is not an answer, and
|
|
109
|
+
* an unanswered control keeps whatever the site said.
|
|
110
|
+
*/
|
|
111
|
+
const flag = (value: unknown, fallback: boolean): boolean =>
|
|
112
|
+
typeof value === 'boolean' ? value : fallback;
|
|
113
|
+
|
|
114
|
+
/** A level inside the decided range, or nothing — a typo is not a depth. */
|
|
115
|
+
const depth = (value: unknown): number | undefined =>
|
|
116
|
+
typeof value === 'number' && TOC_DEPTHS.includes(value) ? value : undefined;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The eight answers, from the page's frontmatter over the site's config.
|
|
120
|
+
*
|
|
121
|
+
* The page wins wherever it speaks. That is what lets a site which hid the
|
|
122
|
+
* breadcrumb everywhere put it back on the one page whose trail is the point —
|
|
123
|
+
* a page that could only ever turn things OFF would have no way to say it.
|
|
124
|
+
*/
|
|
125
|
+
export function duxtPageControls(
|
|
126
|
+
page: unknown,
|
|
127
|
+
site: DuxtPageControlDefaults = {}
|
|
128
|
+
): DuxtPageControls {
|
|
129
|
+
const fields = (page ?? {}) as DuxtPageControlFields;
|
|
130
|
+
const toc = fields.toc;
|
|
131
|
+
// The object form is the DEPTH, never the switch: `toc: { maxDepth: 4 }` is a
|
|
132
|
+
// page asking for more of the column, not a page turning it on.
|
|
133
|
+
const object = toc && typeof toc === 'object' ? toc : undefined;
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
toc: flag(toc, true),
|
|
137
|
+
tocMaxDepth:
|
|
138
|
+
depth(object?.maxDepth) ?? depth(site.tocMaxDepth) ?? DUXT_TOC_MAX_DEPTH,
|
|
139
|
+
breadcrumb: flag(fields.breadcrumb, site.breadcrumb !== false),
|
|
140
|
+
prevNext: flag(fields.prevNext, true),
|
|
141
|
+
feedback: flag(fields.feedback, true),
|
|
142
|
+
pageInfo: flag(fields.pageInfo, true),
|
|
143
|
+
copyPage: flag(fields.copyPage, true),
|
|
144
|
+
// The one control that is OFF until asked for: every other field removes
|
|
145
|
+
// something the shell draws, this one removes a constraint.
|
|
146
|
+
fullWidth: flag(fields.fullWidth, false),
|
|
147
|
+
search: flag(fields.search, true)
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Is this page offered to a reader who is looking for it rather than at it?
|
|
153
|
+
*
|
|
154
|
+
* ONE predicate, four surfaces: the client search index, its fuzzy fallback,
|
|
155
|
+
* the MCP `search_docs` tool and the two llms indexes each have to answer this,
|
|
156
|
+
* and a rule written four times is a rule three of them eventually disagree
|
|
157
|
+
* with. It is deliberately NOT a rule about the URL — an excluded page is still
|
|
158
|
+
* served, still canonical, still in the sitemap and still readable by
|
|
159
|
+
* `read_page`. It is un-findable, not unpublished.
|
|
160
|
+
*
|
|
161
|
+
* IT CAN ONLY EVER BE AS GOOD AS THE COLUMN BEHIND IT. A page arrives here from
|
|
162
|
+
* the database, and Content casts a boolean column with `Boolean(value)` on the
|
|
163
|
+
* way out — so a nullable column hands this predicate `false` for a page that
|
|
164
|
+
* never mentioned `search`, and no comparison written here can tell that apart
|
|
165
|
+
* from a refusal (#88). The `search` field in `pageSchema` therefore carries a
|
|
166
|
+
* default, and that is what makes this line mean what it reads as.
|
|
167
|
+
*/
|
|
168
|
+
export const duxtPageSearchable = (page: unknown): boolean =>
|
|
169
|
+
(page as DuxtPageControlFields | undefined | null)?.search !== false;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Content's outline, cut to the depth this page asked for.
|
|
173
|
+
*
|
|
174
|
+
* Content nests headings by RELATIVE depth and as deep as the document goes,
|
|
175
|
+
* while `DuxtToc` draws exactly two levels. So this does what `generatedToc`
|
|
176
|
+
* already does for a generated page: read the tree back out in document order,
|
|
177
|
+
* drop what is deeper than the maximum, and collapse everything that is left
|
|
178
|
+
* below the first level into it. The written page and the generated one then
|
|
179
|
+
* cannot draw the same outline two different ways.
|
|
180
|
+
*
|
|
181
|
+
* Dropping a heading drops its children with it — they are deeper by
|
|
182
|
+
* construction, so keeping them would reparent a sub-section under a heading
|
|
183
|
+
* the reader was not shown.
|
|
184
|
+
*/
|
|
185
|
+
export function duxtTocLinks(
|
|
186
|
+
links: DuxtTocLink[] | undefined,
|
|
187
|
+
maxDepth: number = DUXT_TOC_MAX_DEPTH
|
|
188
|
+
): DuxtTocLink[] {
|
|
189
|
+
const limit = depth(maxDepth) ?? DUXT_TOC_MAX_DEPTH;
|
|
190
|
+
const flat: DuxtTocLink[] = [];
|
|
191
|
+
|
|
192
|
+
const walk = (nodes: DuxtTocLink[]) => {
|
|
193
|
+
for (const node of nodes) {
|
|
194
|
+
if (node.depth > limit) continue;
|
|
195
|
+
|
|
196
|
+
const { children, ...link } = node;
|
|
197
|
+
flat.push(link);
|
|
198
|
+
if (children?.length) walk(children);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
walk(links ?? []);
|
|
203
|
+
|
|
204
|
+
const nested: DuxtTocLink[] = [];
|
|
205
|
+
|
|
206
|
+
for (const link of flat) {
|
|
207
|
+
const parent = nested.at(-1);
|
|
208
|
+
|
|
209
|
+
// A page whose first heading is an `h3` still needs a row to hang under.
|
|
210
|
+
if (link.depth > 2 && parent) (parent.children ??= []).push(link);
|
|
211
|
+
else nested.push({ ...link, depth: 2 });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return nested;
|
|
215
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pill: the one look every "pick one of these" strip on the site shares.
|
|
3
|
+
*
|
|
4
|
+
* Code tabs, the request samples' language tabs, the package managers, the
|
|
5
|
+
* sample clients, the media types, the form/JSON switch and the demo window's
|
|
6
|
+
* pages are all the same control — choose one spelling of one thing — and they
|
|
7
|
+
* read as one only if they are drawn alike. Each of them used to carry its own
|
|
8
|
+
* class string, and no two of the seven agreed on padding, weight or hover.
|
|
9
|
+
*
|
|
10
|
+
* Two tones, because the strips sit on two kinds of surface. `raised` lifts
|
|
11
|
+
* the chosen pill off a muted bar the way a tab does; on the page's own
|
|
12
|
+
* background that shadow is invisible, so `flat` marks it with the muted fill
|
|
13
|
+
* instead.
|
|
14
|
+
*
|
|
15
|
+
* `active: 'tab'` for a reka-ui trigger, which knows its own state and says so
|
|
16
|
+
* in `data-state` — the classes key off that rather than off a prop the
|
|
17
|
+
* component would have to be handed twice.
|
|
18
|
+
*
|
|
19
|
+
* Full literal class strings throughout: Tailwind finds classes by reading the
|
|
20
|
+
* source, and a string assembled from pieces is a class it never generates.
|
|
21
|
+
*/
|
|
22
|
+
const BASE =
|
|
23
|
+
'inline-flex shrink-0 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
|
24
|
+
|
|
25
|
+
const SIZES = {
|
|
26
|
+
sm: 'px-2 py-0.5 text-xs',
|
|
27
|
+
md: 'px-2.5 py-1 text-xs',
|
|
28
|
+
lg: 'px-3 py-1.5 text-sm'
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
const STATES = {
|
|
32
|
+
raised: {
|
|
33
|
+
on: 'bg-background text-foreground shadow-sm',
|
|
34
|
+
off: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
|
35
|
+
tab: 'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:bg-accent data-[state=inactive]:hover:text-foreground'
|
|
36
|
+
},
|
|
37
|
+
flat: {
|
|
38
|
+
on: 'bg-muted text-foreground',
|
|
39
|
+
off: 'text-muted-foreground hover:text-foreground',
|
|
40
|
+
tab: 'data-[state=active]:bg-muted data-[state=active]:text-foreground data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:text-foreground'
|
|
41
|
+
}
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
export type DuxtPillSize = keyof typeof SIZES;
|
|
45
|
+
export type DuxtPillTone = keyof typeof STATES;
|
|
46
|
+
|
|
47
|
+
export function duxtPill(
|
|
48
|
+
active: boolean | 'tab',
|
|
49
|
+
{
|
|
50
|
+
size = 'md',
|
|
51
|
+
tone = 'raised'
|
|
52
|
+
}: { size?: DuxtPillSize; tone?: DuxtPillTone } = {}
|
|
53
|
+
): string {
|
|
54
|
+
const state = STATES[tone];
|
|
55
|
+
|
|
56
|
+
return [
|
|
57
|
+
BASE,
|
|
58
|
+
SIZES[size],
|
|
59
|
+
active === 'tab' ? state.tab : active ? state.on : state.off
|
|
60
|
+
].join(' ');
|
|
61
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How wide a documentation image really is, and which files must not be touched.
|
|
3
|
+
*
|
|
4
|
+
* THE LAYER OWNS THE COLUMN, NOT THE PROVIDER. `@nuxt/image` already decides
|
|
5
|
+
* where an image is transformed — ipx on a Node server, nothing at all on a
|
|
6
|
+
* Worker, a CDN wherever a consumer has configured one — and it already ships a
|
|
7
|
+
* Tailwind-shaped `screens` map. None of that belongs here: a layer that pins a
|
|
8
|
+
* provider is a layer every consumer has to undo, and a layer that restates the
|
|
9
|
+
* breakpoints makes a consumer's own `image.screens` silently ineffective.
|
|
10
|
+
*
|
|
11
|
+
* What the layer does know, and `@nuxt/image` cannot, is the width of the box
|
|
12
|
+
* the theme draws an image into. That is the whole contribution below: two
|
|
13
|
+
* `sizes` policies, written in the module's own syntax, naming only the screens
|
|
14
|
+
* it already ships so a consumer that redefines them keeps the redefinition.
|
|
15
|
+
*
|
|
16
|
+
* A `sizes` string is what turns a bare `src` into a `srcset`. Without one
|
|
17
|
+
* `NuxtImg` emits a single file, so a 2400px screenshot is downloaded whole
|
|
18
|
+
* onto a phone to be drawn into a 48rem column.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The prose column, in CSS pixels — `max-w-3xl` on the article in every layout
|
|
23
|
+
* that renders Markdown.
|
|
24
|
+
*
|
|
25
|
+
* Stated as a number rather than only inside the policy string because it is
|
|
26
|
+
* the fact the policy is derived FROM, and the thing that has to change first
|
|
27
|
+
* if the column ever does.
|
|
28
|
+
*/
|
|
29
|
+
export const PROSE_COLUMN_PX = 768;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The in-page image.
|
|
33
|
+
*
|
|
34
|
+
* Below `md` the column is the viewport less the layout's own `px-4` gutters,
|
|
35
|
+
* and `95vw` is that minus a rounding margin — over-fetching a few pixels,
|
|
36
|
+
* never under. From `md` up the article is capped at `max-w-3xl` whatever the
|
|
37
|
+
* screen, so the slot stops growing and the policy says so with a fixed pixel
|
|
38
|
+
* width instead of a fraction that would keep climbing on a 4K display.
|
|
39
|
+
*
|
|
40
|
+
* Two entries, not five: `@nuxt/image` gives the widest entry no media query
|
|
41
|
+
* and hands every other one the query of the entry above it, so `lg`, `xl` and
|
|
42
|
+
* `2xl` would each restate `768px` and buy nothing.
|
|
43
|
+
*/
|
|
44
|
+
export const PROSE_IMAGE_SIZES = 'sm:95vw md:768px';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The same image in the zoom dialog, which is a different request on purpose.
|
|
48
|
+
*
|
|
49
|
+
* "Full size" used to mean whatever the author happened to upload, because
|
|
50
|
+
* there was only ever one file. The dialog is `min(96vw, 80rem)` with an 8px
|
|
51
|
+
* inset, so this policy tracks the viewport up to that cap and then stops —
|
|
52
|
+
* four rungs rather than two because a dialog spans the width of the screen and
|
|
53
|
+
* a phone opening one must not be handed a desktop file.
|
|
54
|
+
*
|
|
55
|
+
* A page's own `sizes` deliberately does not reach here: it describes where the
|
|
56
|
+
* image sits in the prose, and the dialog is not in the prose.
|
|
57
|
+
*/
|
|
58
|
+
export const PROSE_IMAGE_ZOOM_SIZES = 'sm:96vw md:96vw lg:96vw xl:1264px';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The policy for one image: the page's own, or the layer's.
|
|
62
|
+
*
|
|
63
|
+
* MDC hands every attribute over as a string, so `sizes=""` on a page that
|
|
64
|
+
* meant to say nothing arrives as an empty string rather than as undefined —
|
|
65
|
+
* and an empty `sizes` is not "no override", it is a policy with no entries,
|
|
66
|
+
* which produces no `srcset` at all.
|
|
67
|
+
*/
|
|
68
|
+
export function proseImageSizes(sizes?: string): string {
|
|
69
|
+
const own = sizes?.trim();
|
|
70
|
+
|
|
71
|
+
return own ? own : PROSE_IMAGE_SIZES;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Formats an image provider must be kept away from.
|
|
76
|
+
*
|
|
77
|
+
* An SVG has no pixels to resample — asking for a 608px wide one rasterises a
|
|
78
|
+
* file that was sharp at every size — and a GIF run through a raster pipeline
|
|
79
|
+
* comes back as its first frame, the animation silently gone. Both are served
|
|
80
|
+
* as the author committed them, in the page and in the dialog alike.
|
|
81
|
+
*
|
|
82
|
+
* Stated by this component rather than assumed of the provider: some pass these
|
|
83
|
+
* through, some do not, and a documentation layer cannot know which one a
|
|
84
|
+
* consumer configured.
|
|
85
|
+
*
|
|
86
|
+
* The extension is read off the PATH. A query string is where a CDN puts its
|
|
87
|
+
* own parameters and a fragment is not part of the file at all, so neither may
|
|
88
|
+
* decide the format — and `/svg/shot.png` is a PNG in a directory, not a
|
|
89
|
+
* vector.
|
|
90
|
+
*/
|
|
91
|
+
export function proseImagePassThrough(src?: string): boolean {
|
|
92
|
+
if (!src) return false;
|
|
93
|
+
|
|
94
|
+
const path = src.split(/[?#]/)[0] ?? '';
|
|
95
|
+
|
|
96
|
+
return /\.(?:svg|gif)$/i.test(path);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The images whose DIALOG variants a static build has to write ahead of time.
|
|
101
|
+
*
|
|
102
|
+
* A generated site learns which variants to produce from the URLs a render
|
|
103
|
+
* emitted — `NuxtImg` appends each one to an `x-nitro-prerender` header and
|
|
104
|
+
* Nitro's crawler prerenders what that header names. The dialog is not rendered
|
|
105
|
+
* until someone opens it, so on a generated site its variants were requested by
|
|
106
|
+
* nobody, written by nobody, and the first click found a 404.
|
|
107
|
+
*
|
|
108
|
+
* `ProseImg` therefore asks the provider for the dialog's sizes during
|
|
109
|
+
* prerender, which registers the URLs without putting them in any markup. This
|
|
110
|
+
* is the list it asks for, and it is a function rather than a loop in the
|
|
111
|
+
* component because WHICH images belong on it is the part worth stating:
|
|
112
|
+
*
|
|
113
|
+
* - An image that cannot be zoomed has no dialog to open. Registering it writes
|
|
114
|
+
* a full set of dialog-sized files nobody can ever request — output inflation
|
|
115
|
+
* for a feature that is switched off on that image.
|
|
116
|
+
* - A pass-through format is served exactly as committed, so there is no
|
|
117
|
+
* variant to write and asking for one would rasterise a vector.
|
|
118
|
+
*/
|
|
119
|
+
export function proseImageZoomPrerenderSources(
|
|
120
|
+
zoomable: boolean,
|
|
121
|
+
srcs: (string | undefined)[]
|
|
122
|
+
): string[] {
|
|
123
|
+
if (!zoomable) return [];
|
|
124
|
+
|
|
125
|
+
return srcs.filter(
|
|
126
|
+
(src): src is string => Boolean(src) && !proseImagePassThrough(src)
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a Command-K row says about itself beyond its title.
|
|
3
|
+
*
|
|
4
|
+
* A title is an identity on a site with one source, one version and one
|
|
5
|
+
* language. It is not one here: three `Harbour` entries under "Recently viewed"
|
|
6
|
+
* were three different pages, and the palette gave the reader no way to tell
|
|
7
|
+
* which was which. The same holds for a search hit — two versions of a page
|
|
8
|
+
* carry the same heading, and the ranking that put them side by side is exactly
|
|
9
|
+
* what makes them impossible to tell apart.
|
|
10
|
+
*
|
|
11
|
+
* So every row carries one compact line: `section · source/version · route`,
|
|
12
|
+
* with the parts a site does not have left out. Built from what the page,
|
|
13
|
+
* section and version models already know — there is no second index here, and
|
|
14
|
+
* nothing about ranking changes.
|
|
15
|
+
*/
|
|
16
|
+
import { asText } from './duxt-text';
|
|
17
|
+
import { currentSection, sectionsForPath } from './section-scope';
|
|
18
|
+
import { sourceForPath } from './version-paths';
|
|
19
|
+
|
|
20
|
+
/** Between the parts. A middle dot, not a slash: the route holds slashes. */
|
|
21
|
+
const SEPARATOR = ' · ';
|
|
22
|
+
|
|
23
|
+
/** The minimum a section entry has to carry to be named. */
|
|
24
|
+
interface Labelled {
|
|
25
|
+
label?: DuxtText;
|
|
26
|
+
to?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The minimum a source has to carry to place a route and name its edition. */
|
|
30
|
+
interface Edition {
|
|
31
|
+
prefix: string;
|
|
32
|
+
repo?: string;
|
|
33
|
+
version?: string;
|
|
34
|
+
/** Present on a generated section, which places itself — see `sectionsForPath`. */
|
|
35
|
+
generated?: unknown;
|
|
36
|
+
/** Looks the source's display name up in `names` below, where there is one. */
|
|
37
|
+
collection?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** How much of the line a caller wants, and what it wants the source called. */
|
|
41
|
+
export interface SearchContextOptions {
|
|
42
|
+
/**
|
|
43
|
+
* Display names by collection — see `sourceDisplayNames`.
|
|
44
|
+
*
|
|
45
|
+
* With one, the source slot says what a source is CALLED rather than which
|
|
46
|
+
* URL segment it occupies: the root documentation has no segment and so said
|
|
47
|
+
* nothing at all, and a source whose segment is an abbreviation said the
|
|
48
|
+
* abbreviation. Without one the slot keeps its `repo/version` spelling.
|
|
49
|
+
*/
|
|
50
|
+
names?: ReadonlyMap<string, string>;
|
|
51
|
+
/**
|
|
52
|
+
* `false` where something above the row already states where it came from —
|
|
53
|
+
* the caption at the head of a run of results, in the search list.
|
|
54
|
+
*
|
|
55
|
+
* The line then carries only what is the ROW's own, its section and its
|
|
56
|
+
* route, which are the parts that still differ from its neighbours. Saying
|
|
57
|
+
* the source again under a caption that just said it is the repetition this
|
|
58
|
+
* line exists to remove.
|
|
59
|
+
*/
|
|
60
|
+
source?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The section a route belongs to.
|
|
65
|
+
*
|
|
66
|
+
* `currentSection`, not a `startsWith` of its own: the longest match wins and
|
|
67
|
+
* the comparison is segment-aware, so `/harbour/api` is the API rather than the
|
|
68
|
+
* documentation it hangs under, and `/guides-old` is not inside `/guides`.
|
|
69
|
+
*/
|
|
70
|
+
export function sectionLabelForPath(
|
|
71
|
+
path: string,
|
|
72
|
+
sections: readonly Labelled[],
|
|
73
|
+
sources: readonly Edition[] = []
|
|
74
|
+
): string | undefined {
|
|
75
|
+
// AT THE ROUTE'S EDITION. The sections are written at the default one, so a
|
|
76
|
+
// hit on `/v0.2.0/concepts/sources` matched nothing and fell back to the
|
|
77
|
+
// generic label — see `sectionsForPath`, which the row and the sidebar read
|
|
78
|
+
// for the same reason.
|
|
79
|
+
return asText(
|
|
80
|
+
currentSection(sectionsForPath([...sections], [...sources], path), path)
|
|
81
|
+
?.label
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `section · source/version · route`, minus whatever the site has not got. */
|
|
86
|
+
export function searchContext(
|
|
87
|
+
path: string,
|
|
88
|
+
sections: readonly Labelled[],
|
|
89
|
+
sources: readonly Edition[],
|
|
90
|
+
options: SearchContextOptions = {}
|
|
91
|
+
): string {
|
|
92
|
+
const route = path.split('#')[0] ?? '';
|
|
93
|
+
const source =
|
|
94
|
+
options.source === false ? undefined : sourceForPath(route, [...sources]);
|
|
95
|
+
const named = source?.collection
|
|
96
|
+
? options.names?.get(source.collection)
|
|
97
|
+
: undefined;
|
|
98
|
+
|
|
99
|
+
return [
|
|
100
|
+
sectionLabelForPath(route, sections, sources),
|
|
101
|
+
// A NAME is prose and takes a part of its own; a segment is an address and
|
|
102
|
+
// keeps the `repo/version` spelling, which reads as the path it is.
|
|
103
|
+
...(named
|
|
104
|
+
? [named, source?.version]
|
|
105
|
+
: [[source?.repo, source?.version].filter(Boolean).join('/')]),
|
|
106
|
+
route
|
|
107
|
+
]
|
|
108
|
+
.filter((part) => part && part.trim())
|
|
109
|
+
.join(SEPARATOR);
|
|
110
|
+
}
|