@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,248 @@
|
|
|
1
|
+
import { asText } from './duxt-text';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How a search hit says where it came from.
|
|
5
|
+
*
|
|
6
|
+
* The search used to label a hit `[repo, version].join(' ') || prefix || '/'`,
|
|
7
|
+
* which is a URL segment doing a name's job: the site's own documentation sits
|
|
8
|
+
* at the root and therefore showed a bare `/`, and a source whose segment is an
|
|
9
|
+
* abbreviation showed the abbreviation. Neither tells a reader which project
|
|
10
|
+
* they are about to open.
|
|
11
|
+
*
|
|
12
|
+
* So provenance is resolved to a NAME here, once, off the same manifest the
|
|
13
|
+
* rest of the theme reads — and `sources[].name` lets a consumer state the one
|
|
14
|
+
* thing no rule can derive.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Where one hit came from, as the dialog says it. */
|
|
18
|
+
export interface DuxtSearchProvenance {
|
|
19
|
+
/** The source's display name — see `sourceDisplayNames`. */
|
|
20
|
+
name: string;
|
|
21
|
+
/** The generated artefact's own label, where the hit came out of one. */
|
|
22
|
+
artefact?: string;
|
|
23
|
+
/** The edition, where the site publishes more than one. */
|
|
24
|
+
version?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One search result, and whether it opens a run of shared provenance. */
|
|
28
|
+
export interface DuxtSearchRow<T> {
|
|
29
|
+
hit: T;
|
|
30
|
+
/**
|
|
31
|
+
* Drawn above this hit, once per run. Decorative: the same text reaches a
|
|
32
|
+
* screen reader through `provenance` on every row, so the visible caption is
|
|
33
|
+
* `aria-hidden` and never read twice.
|
|
34
|
+
*/
|
|
35
|
+
caption?: string;
|
|
36
|
+
/** This hit's own provenance, whether or not it drew the caption. */
|
|
37
|
+
provenance?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `Demo · Demo API · v3.x` — source, artefact, edition, in that order. */
|
|
41
|
+
export const provenanceLabel = (source: DuxtSearchProvenance) =>
|
|
42
|
+
[source.name, source.artefact, source.version].filter(Boolean).join(' · ');
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Results, with the repeated source label lifted off them.
|
|
46
|
+
*
|
|
47
|
+
* A badge on every row spelled the same source name five times down a list of
|
|
48
|
+
* five hits from one repository — the noise this issue is about. A caption at
|
|
49
|
+
* the head of each RUN says it once.
|
|
50
|
+
*
|
|
51
|
+
* A run is CONTIGUOUS, which is the whole constraint. Gathering every hit of
|
|
52
|
+
* one source under a single heading would re-sort the list, and the ranking is
|
|
53
|
+
* the thing the merge across sources exists to produce — see `useDuxtSearch`.
|
|
54
|
+
* So provenance that comes back changes nothing about order: it simply opens a
|
|
55
|
+
* second run.
|
|
56
|
+
*/
|
|
57
|
+
export function searchRows<T extends { source?: DuxtSearchProvenance }>(
|
|
58
|
+
hits: T[]
|
|
59
|
+
): DuxtSearchRow<T>[] {
|
|
60
|
+
let previous: string | undefined;
|
|
61
|
+
|
|
62
|
+
return hits.map((hit) => {
|
|
63
|
+
const provenance = hit.source ? provenanceLabel(hit.source) : undefined;
|
|
64
|
+
const caption =
|
|
65
|
+
provenance && provenance !== previous ? provenance : undefined;
|
|
66
|
+
previous = provenance;
|
|
67
|
+
|
|
68
|
+
return { hit, caption, provenance };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** How much of a section's text a preview may show. */
|
|
73
|
+
const EXCERPT_LENGTH = 160;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A plain-text preview of the section a hit sits in.
|
|
77
|
+
*
|
|
78
|
+
* A title and a breadcrumb say where a result IS; they do not say whether it
|
|
79
|
+
* answers the question, and that is the judgement a reader makes before opening
|
|
80
|
+
* a page. So each hit carries a line or two of its own text.
|
|
81
|
+
*
|
|
82
|
+
* Whitespace is collapsed because Content's index stores a section as it was
|
|
83
|
+
* written — newlines, indentation and all — and an excerpt taken raw would
|
|
84
|
+
* spend half its budget on layout the reader cannot see.
|
|
85
|
+
*
|
|
86
|
+
* PLAIN TEXT, and it stays that way. What comes back is the section's
|
|
87
|
+
* characters, `<` and `&` included; the component renders it through
|
|
88
|
+
* interpolation, never `v-html`, so a document that happens to contain markup
|
|
89
|
+
* shows markup rather than running it.
|
|
90
|
+
*/
|
|
91
|
+
export function searchExcerpt(
|
|
92
|
+
content: string | undefined,
|
|
93
|
+
term: string,
|
|
94
|
+
max = EXCERPT_LENGTH
|
|
95
|
+
): string {
|
|
96
|
+
const text = (content ?? '').replace(/\s+/g, ' ').trim();
|
|
97
|
+
if (!text) return '';
|
|
98
|
+
if (text.length <= max) return text;
|
|
99
|
+
|
|
100
|
+
// The window is anchored on the LITERAL term where the text carries it: a
|
|
101
|
+
// preview whose match is off the end is a preview of the wrong sentence. FTS
|
|
102
|
+
// and Fuse both match things a literal search does not, so this often finds
|
|
103
|
+
// nothing — and the opening of a section is a good second answer, because a
|
|
104
|
+
// section's first sentence is usually what it is about.
|
|
105
|
+
const found = term.trim()
|
|
106
|
+
? text.toLowerCase().indexOf(term.trim().toLowerCase())
|
|
107
|
+
: -1;
|
|
108
|
+
|
|
109
|
+
if (found < 0) return `${cut(text, 0, max)}…`;
|
|
110
|
+
|
|
111
|
+
// A third of the window ahead of the match, so the reader sees what leads
|
|
112
|
+
// into it as well as what follows.
|
|
113
|
+
const lead = Math.floor(max / 3);
|
|
114
|
+
const start = Math.max(0, found - lead);
|
|
115
|
+
const end = Math.min(text.length, start + max);
|
|
116
|
+
|
|
117
|
+
return [
|
|
118
|
+
start > 0 ? '…' : '',
|
|
119
|
+
text.slice(start > 0 ? boundary(text, start) : 0, end).trim(),
|
|
120
|
+
end < text.length ? '…' : ''
|
|
121
|
+
].join('');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Forward to the next word start, so a window never opens mid-word. */
|
|
125
|
+
function boundary(text: string, index: number) {
|
|
126
|
+
const space = text.indexOf(' ', index);
|
|
127
|
+
return space < 0 || space > index + 20 ? index : space + 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Back to the last whole word that fits, so a cut never splits one. */
|
|
131
|
+
function cut(text: string, start: number, max: number) {
|
|
132
|
+
const slice = text.slice(start, start + max);
|
|
133
|
+
const space = slice.lastIndexOf(' ');
|
|
134
|
+
return (space > max / 2 ? slice.slice(0, space) : slice).trim();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** As much of a resolved source as naming it needs. */
|
|
138
|
+
export interface DuxtSearchSourceInput {
|
|
139
|
+
collection: string;
|
|
140
|
+
/** The display name a consumer configured, already resolved for the locale. */
|
|
141
|
+
name?: DuxtText;
|
|
142
|
+
/** The source's own URL segment — its identity, where it has one. */
|
|
143
|
+
repo?: string;
|
|
144
|
+
/** `owner/name` of the repository behind it, where there is one. */
|
|
145
|
+
repository?: string;
|
|
146
|
+
/** Folder inside that repository holding the Markdown. */
|
|
147
|
+
path: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A slug as a reader would write it: `client-sdk` → `Client Sdk`.
|
|
152
|
+
*
|
|
153
|
+
* Deliberately not a title-caser — `of`, `and` and the rest are left capitalised
|
|
154
|
+
* rather than guessed at per language, because this runs over a URL segment and
|
|
155
|
+
* a wrong small word reads worse than a consistent one. A consumer who wants
|
|
156
|
+
* the real spelling writes `name`.
|
|
157
|
+
*/
|
|
158
|
+
export const humanizeSlug = (value: string) =>
|
|
159
|
+
value
|
|
160
|
+
.split(/[-_.\s]+/)
|
|
161
|
+
.filter(Boolean)
|
|
162
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
163
|
+
.join(' ');
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The name each collection shows, by collection.
|
|
167
|
+
*
|
|
168
|
+
* The ladder, in order, and every rung is a fallback for the one before it:
|
|
169
|
+
*
|
|
170
|
+
* 1. `sources[].name` — display only, and the only rung a consumer controls.
|
|
171
|
+
* 2. The source's own SEGMENT, humanized. Usually this IS the repository's
|
|
172
|
+
* name: `repoSlug` derives the segment from it unless the source claimed a
|
|
173
|
+
* `slug`, and where a source did claim one, the claim is the deliberate
|
|
174
|
+
* spelling and the better name.
|
|
175
|
+
* 3. The site's name — the answer for a source with no segment at all, which
|
|
176
|
+
* is the site's own root documentation. Unset, `duxt.title` is already the
|
|
177
|
+
* localized "Documentation" label, so that is the bottom rung and it needs
|
|
178
|
+
* no second string to translate.
|
|
179
|
+
*
|
|
180
|
+
* The REPOSITORY deliberately names nothing. Several sources may be read out of
|
|
181
|
+
* one repository — this site's own demo tree and its documentation are — so a
|
|
182
|
+
* repository identifies a source only sometimes, and a rule that is right only
|
|
183
|
+
* sometimes attributes one section to another. It qualifies a collision below,
|
|
184
|
+
* where it has a second name beside it to be read against, and nothing else.
|
|
185
|
+
*
|
|
186
|
+
* A collection never falls through to its own identifier or to `/`: both are
|
|
187
|
+
* addresses, and this is the one place that must not show one.
|
|
188
|
+
*
|
|
189
|
+
* Two DIFFERENT sources landing on one name are then qualified — see
|
|
190
|
+
* `qualified`. Two editions of the SAME source landing on one name are the
|
|
191
|
+
* point of the exercise and left alone: a version and a translation are the
|
|
192
|
+
* same project, and the version caption already tells them apart.
|
|
193
|
+
*/
|
|
194
|
+
export function sourceDisplayNames(
|
|
195
|
+
sources: DuxtSearchSourceInput[],
|
|
196
|
+
siteName: string
|
|
197
|
+
): Map<string, string> {
|
|
198
|
+
const named = sources.map((source) => ({
|
|
199
|
+
source,
|
|
200
|
+
// The source's identity, not the collection's: every version and every
|
|
201
|
+
// language of one source shares it, which is what keeps them one name.
|
|
202
|
+
identity: source.repo ?? '',
|
|
203
|
+
name:
|
|
204
|
+
asText(source.name) ||
|
|
205
|
+
(source.repo && humanizeSlug(source.repo)) ||
|
|
206
|
+
siteName
|
|
207
|
+
}));
|
|
208
|
+
|
|
209
|
+
const identities = new Map<string, Set<string>>();
|
|
210
|
+
for (const { name, identity } of named) {
|
|
211
|
+
const claimed = identities.get(name) ?? new Set<string>();
|
|
212
|
+
claimed.add(identity);
|
|
213
|
+
identities.set(name, claimed);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return new Map(
|
|
217
|
+
named.map(({ source, name }) => [
|
|
218
|
+
source.collection,
|
|
219
|
+
identities.get(name)!.size > 1 ? qualified(name, source, named) : name
|
|
220
|
+
])
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* One colliding name, told apart.
|
|
226
|
+
*
|
|
227
|
+
* The repository first, because "Reference (acme/api)" answers the reader's
|
|
228
|
+
* actual question — which project is this. A monorepo publishing two sections
|
|
229
|
+
* out of one repository cannot be told apart that way, so the folder is the
|
|
230
|
+
* second rung: it is the only thing left that differs, and it is readable.
|
|
231
|
+
*/
|
|
232
|
+
function qualified(
|
|
233
|
+
name: string,
|
|
234
|
+
source: DuxtSearchSourceInput,
|
|
235
|
+
named: { source: DuxtSearchSourceInput; identity: string; name: string }[]
|
|
236
|
+
) {
|
|
237
|
+
const colliding = named.filter((entry) => entry.name === name);
|
|
238
|
+
const repositories = new Set(
|
|
239
|
+
colliding.map((entry) => entry.source.repository ?? '')
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const qualifier =
|
|
243
|
+
repositories.size > 1 && source.repository
|
|
244
|
+
? source.repository
|
|
245
|
+
: source.path;
|
|
246
|
+
|
|
247
|
+
return qualifier ? `${name} (${qualifier})` : name;
|
|
248
|
+
}
|
|
@@ -50,15 +50,65 @@ function documentationFor(source: Area | undefined, sources: Area[]) {
|
|
|
50
50
|
)[0];
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* The version-neutral root of a documentation area.
|
|
55
|
+
*
|
|
56
|
+
* A tree published INSIDE another area's root belongs to that area: a provider
|
|
57
|
+
* reference at `/demo/terraform` is a part of `/demo` in the same way the
|
|
58
|
+
* generated reference beside it is, and treating it as an area of its own
|
|
59
|
+
* would empty the row on its pages and hide its entry on every other one. The
|
|
60
|
+
* root area `''` never adopts anything, or every source would be one area.
|
|
61
|
+
*/
|
|
54
62
|
function areaRoot(source: Area | undefined, sources: Area[]): string {
|
|
63
|
+
const own = treeRoot(source, sources);
|
|
64
|
+
if (!own) return own;
|
|
65
|
+
|
|
66
|
+
const enclosing = sources
|
|
67
|
+
.filter((other) => !other.generated)
|
|
68
|
+
.map((other) => treeRoot(other, sources))
|
|
69
|
+
.filter((root) => root && root !== own && isInside(own, root))
|
|
70
|
+
.sort((a, b) => a.length - b.length)[0];
|
|
71
|
+
|
|
72
|
+
return enclosing ?? own;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The prefix a source WITHOUT a slug has before its version segment.
|
|
77
|
+
*
|
|
78
|
+
* Such a source carries no identity to group its editions by, so they used to
|
|
79
|
+
* keep their own prefixes: `/v0.2.0` was an area apart from the root, and on a
|
|
80
|
+
* site with a second area every section was filtered off the page — the row
|
|
81
|
+
* vanished on every non-default edition of the documentation it belongs to.
|
|
82
|
+
* The version segment is the one thing the editions of one source add to the
|
|
83
|
+
* same base, so it is taken off again — but only where an edition without it
|
|
84
|
+
* actually exists, so a prefix that merely ends like a version stays its own.
|
|
85
|
+
*/
|
|
86
|
+
function unversioned(source: Area, sources: Area[]): string {
|
|
87
|
+
const segment = source.version ? `/${source.version}` : '';
|
|
88
|
+
if (!segment || !source.prefix.endsWith(segment)) return source.prefix;
|
|
89
|
+
|
|
90
|
+
const base = source.prefix.slice(0, -segment.length);
|
|
91
|
+
|
|
92
|
+
return sources.some(
|
|
93
|
+
(other) =>
|
|
94
|
+
!other.generated &&
|
|
95
|
+
!other.repo &&
|
|
96
|
+
other.prefix === base &&
|
|
97
|
+
other.version !== source.version
|
|
98
|
+
)
|
|
99
|
+
? base
|
|
100
|
+
: source.prefix;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The root of the one documentation tree a source belongs to. */
|
|
104
|
+
function treeRoot(source: Area | undefined, sources: Area[]): string {
|
|
55
105
|
const documentation = documentationFor(source, sources);
|
|
56
106
|
if (!documentation) return '';
|
|
57
107
|
|
|
58
108
|
// A slug is the source identity and survives the version segment. All
|
|
59
109
|
// versions of `/demo` are therefore one area, whose shallowest prefix is
|
|
60
|
-
// the default edition.
|
|
61
|
-
if (!documentation.repo) return documentation
|
|
110
|
+
// the default edition.
|
|
111
|
+
if (!documentation.repo) return unversioned(documentation, sources);
|
|
62
112
|
|
|
63
113
|
return sources
|
|
64
114
|
.filter((other) => !other.generated && other.repo === documentation.repo)
|
|
@@ -96,6 +146,17 @@ export function areaForPath(path: string, sources: Area[]): string {
|
|
|
96
146
|
return areaRoot(sourceForPath(path, sources), sources);
|
|
97
147
|
}
|
|
98
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Whether two sources are editions of one documentation tree: the same slug
|
|
151
|
+
* where they carry one, the same unversioned root where they do not. Two
|
|
152
|
+
* slug-less sources are NOT one tree merely for both lacking a slug.
|
|
153
|
+
*/
|
|
154
|
+
function sameTree(a: Area, b: Area, sources: Area[]): boolean {
|
|
155
|
+
if (a.repo || b.repo) return a.repo === b.repo;
|
|
156
|
+
|
|
157
|
+
return unversioned(a, sources) === unversioned(b, sources);
|
|
158
|
+
}
|
|
159
|
+
|
|
99
160
|
/**
|
|
100
161
|
* The entries of one area, in the order the site declared them.
|
|
101
162
|
*
|
|
@@ -134,7 +195,7 @@ export function sectionsForPath<T extends { to?: string }>(
|
|
|
134
195
|
if (
|
|
135
196
|
target &&
|
|
136
197
|
!target.generated &&
|
|
137
|
-
target
|
|
198
|
+
sameTree(target, reader, sources) &&
|
|
138
199
|
target.version !== reader.version
|
|
139
200
|
) {
|
|
140
201
|
return {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A JSON value kept in `localStorage`, read and written without ever throwing.
|
|
3
|
+
*
|
|
4
|
+
* Storage is the one browser API that fails for reasons the page cannot see: a
|
|
5
|
+
* private window, a blocked site, a full quota, or a value some other script
|
|
6
|
+
* wrote under the same key. Every reader here wants the same answer to all of
|
|
7
|
+
* those — "nothing stored" — and every writer the same shrug, because what is
|
|
8
|
+
* kept is a convenience and never worth an error the reader has to read.
|
|
9
|
+
*
|
|
10
|
+
* `unknown` on the way out, deliberately: the stored value crossed a boundary
|
|
11
|
+
* the type system cannot follow, so the caller checks its shape rather than
|
|
12
|
+
* trusting a cast.
|
|
13
|
+
*/
|
|
14
|
+
export function readStoredJson(key: string): unknown {
|
|
15
|
+
try {
|
|
16
|
+
const stored = window.localStorage.getItem(key);
|
|
17
|
+
return stored ? (JSON.parse(stored) as unknown) : undefined;
|
|
18
|
+
} catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether the value was kept. A failure is not reported further than that. */
|
|
24
|
+
export function writeStoredJson(key: string, value: unknown): boolean {
|
|
25
|
+
try {
|
|
26
|
+
window.localStorage.setItem(key, JSON.stringify(value));
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -65,20 +65,13 @@ export function versionChoices(
|
|
|
65
65
|
current: DuxtResolvedSource | undefined,
|
|
66
66
|
configured: DuxtLink[] | undefined
|
|
67
67
|
): DuxtLink[] {
|
|
68
|
-
// A
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
68
|
+
// A GLOBAL generated section has no version of its own and serves one URL,
|
|
69
|
+
// so every entry would move the reader off it. A per-version one — the API
|
|
70
|
+
// reference, the changelog — is scoped below like any other artefact, which
|
|
71
|
+
// is what keeps a reader in it when they change versions.
|
|
72
|
+
if (current?.generated?.versioning === 'global') return [];
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const context = global
|
|
77
|
-
? sources.find(
|
|
78
|
-
(source) =>
|
|
79
|
-
source.repo === current.repo && source.isDefault && !source.generated
|
|
80
|
-
)
|
|
81
|
-
: current;
|
|
74
|
+
const context = current;
|
|
82
75
|
|
|
83
76
|
if (!context) return [];
|
|
84
77
|
|
|
@@ -57,6 +57,27 @@ export function versionPath(
|
|
|
57
57
|
return `${target}${rest}` || '/';
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Where the switcher sends a reader who picks another version.
|
|
62
|
+
*
|
|
63
|
+
* The same page in that version, as `versionPath` answers — unless that page is
|
|
64
|
+
* one the target never published. A release page is the case this exists for:
|
|
65
|
+
* every version publishes its own changelog, and an older one has no page for
|
|
66
|
+
* a release cut after it. `missing` holds the target paths known not to exist,
|
|
67
|
+
* and such a pick lands at the target's own root — the changelog overview of
|
|
68
|
+
* that version — rather than at a 404.
|
|
69
|
+
*/
|
|
70
|
+
export function versionSwitchPath(
|
|
71
|
+
path: string,
|
|
72
|
+
from: string | undefined,
|
|
73
|
+
to: string,
|
|
74
|
+
missing: ReadonlySet<string>
|
|
75
|
+
): string {
|
|
76
|
+
const target = versionPath(path, from, to);
|
|
77
|
+
|
|
78
|
+
return missing.has(target) ? to || '/' : target;
|
|
79
|
+
}
|
|
80
|
+
|
|
60
81
|
/**
|
|
61
82
|
* Re-exported so the app can auto-import it.
|
|
62
83
|
*
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createJiti } from 'jiti';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The cache key for a site's remote sources, as a command.
|
|
6
|
+
*
|
|
7
|
+
* PLAIN JAVASCRIPT for the reason `duxt.mjs` states: Node refuses to strip
|
|
8
|
+
* types from a file under `node_modules`, so a TypeScript entry point dies on
|
|
9
|
+
* the first real install with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`.
|
|
10
|
+
*
|
|
11
|
+
* Everything it decides is in `sources-cache.ts`, where it is typed and tested.
|
|
12
|
+
* This file exists to have no logic in it.
|
|
13
|
+
*
|
|
14
|
+
* Written for a CI step that caches `.data/content` between runs:
|
|
15
|
+
*
|
|
16
|
+
* duxt-cache-key --github >> "$GITHUB_OUTPUT"
|
|
17
|
+
*
|
|
18
|
+
* and readable without one, which is what makes "why did my cache miss" a
|
|
19
|
+
* question with an answer.
|
|
20
|
+
*/
|
|
21
|
+
// `node:sqlite` is not reached on this path, but the config is loaded through
|
|
22
|
+
// the same modules the report loads it through — and an experimental-feature
|
|
23
|
+
// warning in the middle of a `$GITHUB_OUTPUT` capture is a corrupt step output,
|
|
24
|
+
// not merely noise.
|
|
25
|
+
process.removeAllListeners('warning');
|
|
26
|
+
|
|
27
|
+
const guide = 'https://duxt.app/guides/cache-build-work';
|
|
28
|
+
const argv = process.argv.slice(2);
|
|
29
|
+
|
|
30
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
31
|
+
console.log(`Usage: duxt-cache-key [--root <site>] [--github]
|
|
32
|
+
|
|
33
|
+
Print the cache key and paths for remote Content sources.
|
|
34
|
+
Documentation: ${guide}`);
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
|
39
|
+
|
|
40
|
+
const { duxtSourcesCache, runDuxtSourcesCacheCli } =
|
|
41
|
+
await jiti.import('../sources-cache');
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const rootIndex = argv.indexOf('--root');
|
|
45
|
+
const rootDir = rootIndex === -1 ? undefined : argv[rootIndex + 1];
|
|
46
|
+
|
|
47
|
+
const { output, exitCode } = runDuxtSourcesCacheCli(
|
|
48
|
+
argv,
|
|
49
|
+
duxtSourcesCache({ rootDir })
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
console.log(output);
|
|
53
|
+
process.exitCode = exitCode;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// The message, not the stack: everything thrown on this path is a statement
|
|
56
|
+
// about the SITE's own source list, written to be read by whoever wrote it.
|
|
57
|
+
console.error(
|
|
58
|
+
`${error instanceof Error ? error.message : String(error)}\nDocumentation: ${guide}`
|
|
59
|
+
);
|
|
60
|
+
process.exitCode = 1;
|
|
61
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createJiti } from 'jiti';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The rendered-OG-image cache, as a command.
|
|
6
|
+
*
|
|
7
|
+
* PLAIN JAVASCRIPT for the reason `duxt.mjs` states: Node refuses to strip
|
|
8
|
+
* types from a file under `node_modules`, so a TypeScript entry point dies on
|
|
9
|
+
* the first real install with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`.
|
|
10
|
+
*
|
|
11
|
+
* Everything it decides is in `og-image-cache.ts`, where it is typed and
|
|
12
|
+
* tested. This file exists to have no logic in it.
|
|
13
|
+
*
|
|
14
|
+
* Written for a deploy that caches the rendered images between runs — once
|
|
15
|
+
* before the build, to say what to restore and under which key:
|
|
16
|
+
*
|
|
17
|
+
* duxt-og-cache --root www --run "$GITHUB_SHA" --github >> "$GITHUB_OUTPUT"
|
|
18
|
+
*
|
|
19
|
+
* and once after it, to say what that was worth:
|
|
20
|
+
*
|
|
21
|
+
* duxt-og-cache --root www --report --since "$STARTED" --log build.log
|
|
22
|
+
*
|
|
23
|
+
* Both are readable without a workflow, which is what makes "why did my cache
|
|
24
|
+
* miss" and "did it help" questions with answers.
|
|
25
|
+
*/
|
|
26
|
+
// An experimental-feature warning in the middle of a `$GITHUB_OUTPUT` capture
|
|
27
|
+
// is a corrupt step output, not merely noise.
|
|
28
|
+
process.removeAllListeners('warning');
|
|
29
|
+
|
|
30
|
+
const guide = 'https://duxt.app/guides/cache-build-work';
|
|
31
|
+
const argv = process.argv.slice(2);
|
|
32
|
+
|
|
33
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
34
|
+
console.log(`Usage: duxt-og-cache [--root <site>] [--run <id>] [--github]
|
|
35
|
+
duxt-og-cache --report [--root <site>] [--since <ms>] [--log <file>]
|
|
36
|
+
|
|
37
|
+
Print a rendered-OG cache key or report reuse after a build.
|
|
38
|
+
Documentation: ${guide}`);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
|
43
|
+
|
|
44
|
+
const { duxtOgImageCacheCommand } = await jiti.import('../og-image-cache');
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const { output, exitCode } = duxtOgImageCacheCommand(argv);
|
|
48
|
+
|
|
49
|
+
console.log(output);
|
|
50
|
+
process.exitCode = exitCode;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
// The message, not the stack: everything thrown on this path is a statement
|
|
53
|
+
// about the SITE, written to be read by whoever built it.
|
|
54
|
+
console.error(
|
|
55
|
+
`${error instanceof Error ? error.message : String(error)}\nDocumentation: ${guide}`
|
|
56
|
+
);
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { createJiti } from 'jiti';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* The
|
|
5
|
+
* The `duxt` command.
|
|
6
6
|
*
|
|
7
7
|
* PLAIN JAVASCRIPT, and it has to be: Node refuses to strip types from a file
|
|
8
8
|
* under `node_modules`, so a TypeScript entry point runs in this repository —
|
|
@@ -14,8 +14,9 @@ import { createJiti } from 'jiti';
|
|
|
14
14
|
* `duxt-app-config.ts` already reads a site's config, so the rest of the layer
|
|
15
15
|
* needs no build step to be reachable from here.
|
|
16
16
|
*
|
|
17
|
-
* Everything the command decides is in `
|
|
18
|
-
*
|
|
17
|
+
* Everything the command decides is in `cli.ts`, where it is typed and tested.
|
|
18
|
+
* This file exists to have no logic in it: it hands over the arguments, prints
|
|
19
|
+
* the two streams and sets the exit code.
|
|
19
20
|
*/
|
|
20
21
|
// Content's cache is read through `node:sqlite`, which announces itself as
|
|
21
22
|
// experimental on every run. True, and not this command's news to break to
|
|
@@ -25,15 +26,20 @@ process.removeAllListeners('warning');
|
|
|
25
26
|
|
|
26
27
|
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
|
27
28
|
|
|
28
|
-
const {
|
|
29
|
+
const { runDuxtCli } = await jiti.import('../cli');
|
|
30
|
+
const { duxtReport } = await jiti.import('../report');
|
|
29
31
|
|
|
30
32
|
try {
|
|
31
|
-
|
|
33
|
+
// `duxtReport` is passed rather than called: usage has to answer on a site
|
|
34
|
+
// that has never been built, so nothing may read a config or a parse cache
|
|
35
|
+
// until the arguments have been read and found to ask for a report.
|
|
36
|
+
const { stdout, stderr, exitCode } = await runDuxtCli(
|
|
32
37
|
process.argv.slice(2),
|
|
33
|
-
duxtReport
|
|
38
|
+
duxtReport
|
|
34
39
|
);
|
|
35
40
|
|
|
36
|
-
console.log(
|
|
41
|
+
if (stdout) console.log(stdout);
|
|
42
|
+
if (stderr) console.error(stderr);
|
|
37
43
|
process.exitCode = exitCode;
|
|
38
44
|
} catch (error) {
|
|
39
45
|
// The message, not the stack. Everything thrown on this path is a statement
|