@kirchdev/duxt 0.1.1-dev-main.12-4d33ebf → 0.1.1-dev-main.30-1e18e2e
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/app/assets/css/duxt.css +6 -4
- package/app/components/DuxtHeader.vue +17 -2
- package/app/components/DuxtLandingProse.vue +1 -2
- package/app/components/DuxtOpenApiClient.vue +3 -3
- package/app/components/DuxtVersion.vue +33 -3
- package/app/components/content/ProseA.vue +13 -8
- package/app/composables/useDuxtSearch.ts +89 -43
- package/app/types/duxt.d.ts +7 -0
- package/app/utils/documentation-link.ts +14 -0
- package/app/utils/locale-path.ts +4 -1
- package/app/utils/navigation-tree.ts +13 -0
- package/app/utils/search-scope.ts +41 -0
- package/app/utils/version-choices.ts +10 -0
- package/nuxt.config.ts +5 -2
- package/package.json +1 -1
- package/public/devtools/checks.html +1 -1
- package/public/devtools/config.html +1 -0
- package/sections-changelog.ts +17 -7
- package/sections-resolve.ts +19 -5
- package/server/middleware/raw-markdown.ts +37 -50
- package/server/routes/llms-full.txt.get.ts +2 -14
- package/server/routes/llms.txt.get.ts +2 -16
- package/server/utils/llms-pages.ts +87 -0
- package/sources-git.ts +8 -1
- package/sources-resolve.ts +80 -24
- package/sources.ts +5 -1
- package/validate-report.ts +20 -11
package/app/assets/css/duxt.css
CHANGED
|
@@ -331,10 +331,12 @@ details[open] > .duxt-schema-row .duxt-schema-tile .duxt-schema-minus {
|
|
|
331
331
|
set at 0.6875rem in `muted-foreground`: the same idea, two sizes, two
|
|
332
332
|
weights, two colours, on pages a reader moves between.
|
|
333
333
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
334
|
+
Keep the defaults in the components layer so a caller's colour utilities
|
|
335
|
+
can override them. Unlayered rules beat utilities regardless of class order. */
|
|
336
|
+
@layer components {
|
|
337
|
+
.duxt-label {
|
|
338
|
+
@apply text-[0.6875rem] font-semibold tracking-[0.12em] text-muted-foreground uppercase;
|
|
339
|
+
}
|
|
338
340
|
}
|
|
339
341
|
|
|
340
342
|
/* A release group's ENTRIES, which arrive as ordinary Markdown in
|
|
@@ -6,6 +6,14 @@ const localeLink = useDuxtLink();
|
|
|
6
6
|
|
|
7
7
|
const { data: navigation } = await useDuxtNavigation();
|
|
8
8
|
|
|
9
|
+
// Nitro's HTML crawler treats v0.1.0 as a file with extension .0 and skips it.
|
|
10
|
+
// Register known pages explicitly so their HTML and Nuxt payloads are emitted.
|
|
11
|
+
if (import.meta.prerender) {
|
|
12
|
+
prerenderRoutes(
|
|
13
|
+
navigationPagePaths(navigation.value ?? []).map((path) => localeLink(path)!)
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
9
17
|
// The mobile sheet shows the same branch the sidebar does.
|
|
10
18
|
const { items } = useDuxtSection(navigation);
|
|
11
19
|
|
|
@@ -148,7 +156,14 @@ function current(to?: string) {
|
|
|
148
156
|
<UiSheetContent side="left" class="flex w-80 flex-col gap-0 p-0">
|
|
149
157
|
<UiSheetHeader class="border-b pr-14">
|
|
150
158
|
<UiSheetTitle class="flex items-center gap-2">
|
|
151
|
-
<
|
|
159
|
+
<NuxtLink
|
|
160
|
+
:to="localeLink('/')"
|
|
161
|
+
:aria-label="duxt.logo?.alt || duxt.title"
|
|
162
|
+
class="flex items-center gap-2"
|
|
163
|
+
@click="sheetOpen = false"
|
|
164
|
+
>
|
|
165
|
+
<DuxtBrand />
|
|
166
|
+
</NuxtLink>
|
|
152
167
|
|
|
153
168
|
<!-- One version and no choice: a fact about the project, so it
|
|
154
169
|
sits with the identity as a badge rather than filling a
|
|
@@ -320,7 +335,7 @@ function current(to?: string) {
|
|
|
320
335
|
</ul>
|
|
321
336
|
</template>
|
|
322
337
|
|
|
323
|
-
<template v-if="items.length">
|
|
338
|
+
<template v-if="path !== '/' && items.length">
|
|
324
339
|
<p
|
|
325
340
|
class="mt-6 mb-2 border-t px-2 pt-4 text-[11px] font-semibold tracking-wider text-muted-foreground uppercase first:mt-0 first:border-t-0 first:pt-0"
|
|
326
341
|
>
|
|
@@ -42,8 +42,7 @@ const localeLink = useDuxtLink();
|
|
|
42
42
|
<component
|
|
43
43
|
:is="`h${level}`"
|
|
44
44
|
:id="headingId"
|
|
45
|
-
class="font-semibold tracking-tight text-balance"
|
|
46
|
-
:class="level === 2 ? 'text-2xl sm:text-3xl' : 'text-xl sm:text-2xl'"
|
|
45
|
+
class="text-2xl font-semibold tracking-tight text-balance sm:text-3xl"
|
|
47
46
|
>
|
|
48
47
|
{{ prose.title }}
|
|
49
48
|
</component>
|
|
@@ -1161,7 +1161,7 @@ function pretty(text: string): string {
|
|
|
1161
1161
|
<div
|
|
1162
1162
|
:class="
|
|
1163
1163
|
split
|
|
1164
|
-
? ['self-start', reverse ? 'lg:order-2' : 'lg:order-1']
|
|
1164
|
+
? ['min-w-0 self-start', reverse ? 'lg:order-2' : 'lg:order-1']
|
|
1165
1165
|
: 'border-t px-4 py-3'
|
|
1166
1166
|
"
|
|
1167
1167
|
>
|
|
@@ -1199,14 +1199,14 @@ function pretty(text: string): string {
|
|
|
1199
1199
|
class="flex min-h-11 items-center gap-1 border-b bg-muted/40 px-2 py-1.5"
|
|
1200
1200
|
>
|
|
1201
1201
|
<TabsList
|
|
1202
|
-
class="flex items-center gap-1"
|
|
1202
|
+
class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"
|
|
1203
1203
|
:aria-label="$t('duxt.openapi.client.samples') as string"
|
|
1204
1204
|
>
|
|
1205
1205
|
<TabsTrigger
|
|
1206
1206
|
v-for="entry in groups"
|
|
1207
1207
|
:key="entry"
|
|
1208
1208
|
:value="entry"
|
|
1209
|
-
class="flex cursor-pointer items-center gap-1.5 rounded-md px-2.5 py-1 font-mono text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
|
1209
|
+
class="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2.5 py-1 font-mono text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
|
1210
1210
|
>
|
|
1211
1211
|
<Icon :name="groupIcon(entry)" class="size-3.5" />
|
|
1212
1212
|
{{ entry }}
|
|
@@ -24,6 +24,7 @@ const props = withDefaults(defineProps<{ variant?: 'badge' | 'block' }>(), {
|
|
|
24
24
|
const duxt = useDuxtConfig();
|
|
25
25
|
const path = useDuxtPath();
|
|
26
26
|
const localeLink = useDuxtLink();
|
|
27
|
+
const { collection, source } = useDuxtCollection();
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Versions come from the resolved source manifest, so the control can only
|
|
@@ -34,6 +35,31 @@ const localeLink = useDuxtLink();
|
|
|
34
35
|
*/
|
|
35
36
|
const { choices: versions } = useDuxtVersion();
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A changelog is one global history, so it deliberately has no version
|
|
40
|
+
* selector. Its current release is still useful context, though: read the
|
|
41
|
+
* latest one from the overview's generated frontmatter instead of falling
|
|
42
|
+
* back to the site's package version. The parser writes that value while
|
|
43
|
+
* turning the changelog into the overview and release pages.
|
|
44
|
+
*/
|
|
45
|
+
const changelog = computed(
|
|
46
|
+
() =>
|
|
47
|
+
source.value?.generated?.type === 'changelog' &&
|
|
48
|
+
source.value.generated.versioning === 'global'
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const { data: changelogPage } = await useAsyncData(
|
|
52
|
+
() => `duxt-changelog-version-${collection.value}-${path.value}`,
|
|
53
|
+
async () => {
|
|
54
|
+
if (!changelog.value) return undefined;
|
|
55
|
+
|
|
56
|
+
return (await queryCollection(collection.value as DuxtCollectionArg)
|
|
57
|
+
.path(path.value)
|
|
58
|
+
.first()) as { release?: string } | null;
|
|
59
|
+
},
|
|
60
|
+
{ watch: [collection, path, changelog] }
|
|
61
|
+
);
|
|
62
|
+
|
|
37
63
|
const current = computed(() =>
|
|
38
64
|
sourceForPath(
|
|
39
65
|
path.value,
|
|
@@ -41,6 +67,10 @@ const current = computed(() =>
|
|
|
41
67
|
)
|
|
42
68
|
);
|
|
43
69
|
|
|
70
|
+
const label = computed(
|
|
71
|
+
() => changelogPage.value?.release ?? current.value?.label ?? duxt.version
|
|
72
|
+
);
|
|
73
|
+
|
|
44
74
|
const { t, te } = useI18n();
|
|
45
75
|
|
|
46
76
|
/**
|
|
@@ -94,7 +124,7 @@ function pathIn(version: { to?: string }) {
|
|
|
94
124
|
|
|
95
125
|
<span class="grid min-w-0 flex-1 leading-tight">
|
|
96
126
|
<span class="truncate font-mono text-sm font-medium">
|
|
97
|
-
{{
|
|
127
|
+
{{ label }}
|
|
98
128
|
</span>
|
|
99
129
|
<span class="truncate text-xs text-muted-foreground">
|
|
100
130
|
{{ (current && caption(current)) ?? $t('duxt.version.label') }}
|
|
@@ -112,7 +142,7 @@ function pathIn(version: { to?: string }) {
|
|
|
112
142
|
variant="secondary"
|
|
113
143
|
class="gap-1 font-mono text-[10px] hover:bg-accent"
|
|
114
144
|
>
|
|
115
|
-
{{
|
|
145
|
+
{{ label }}
|
|
116
146
|
<Icon name="lucide:chevron-down" class="size-3 opacity-60" />
|
|
117
147
|
</UiBadge>
|
|
118
148
|
</button>
|
|
@@ -163,6 +193,6 @@ function pathIn(version: { to?: string }) {
|
|
|
163
193
|
variant="secondary"
|
|
164
194
|
class="font-mono text-[10px]"
|
|
165
195
|
>
|
|
166
|
-
{{
|
|
196
|
+
{{ label }}
|
|
167
197
|
</UiBadge>
|
|
168
198
|
</template>
|
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* mid-sentence.
|
|
12
12
|
*
|
|
13
13
|
* So an absolute internal path is read as relative TO ITS OWN SOURCE, and the
|
|
14
|
-
* prefix is put back here.
|
|
15
|
-
*
|
|
14
|
+
* prefix is put back here. `~/` is the explicit website-root escape hatch for
|
|
15
|
+
* a link that has to leave that source. A path that already carries the prefix
|
|
16
|
+
* is left alone, so a link written the long way round still works.
|
|
16
17
|
*/
|
|
17
18
|
const props = defineProps<{
|
|
18
19
|
href?: string;
|
|
@@ -26,22 +27,26 @@ const external = computed(() =>
|
|
|
26
27
|
Boolean(props.href && /^(?:[a-z]+:|\/\/)/i.test(props.href))
|
|
27
28
|
);
|
|
28
29
|
|
|
30
|
+
const internal = computed(() =>
|
|
31
|
+
Boolean(props.href?.startsWith('/') || props.href?.startsWith('~/'))
|
|
32
|
+
);
|
|
33
|
+
|
|
29
34
|
const to = computed(() => {
|
|
30
35
|
const href = props.href ?? '';
|
|
31
|
-
if (external.value || !
|
|
36
|
+
if (external.value || !internal.value) return href;
|
|
32
37
|
|
|
33
38
|
const prefix = source.value?.prefix ?? '';
|
|
34
|
-
const [
|
|
35
|
-
const
|
|
36
|
-
|
|
39
|
+
const [pathAndQuery, hash] = href.split('#');
|
|
40
|
+
const [path, query] = pathAndQuery!.split('?');
|
|
41
|
+
const resolved = resolveDocumentationPath(path!, prefix);
|
|
37
42
|
|
|
38
|
-
return `${localeLink(resolved)}${hash ? `#${hash}` : ''}`;
|
|
43
|
+
return `${localeLink(resolved)}${query ? `?${query}` : ''}${hash ? `#${hash}` : ''}`;
|
|
39
44
|
});
|
|
40
45
|
</script>
|
|
41
46
|
|
|
42
47
|
<template>
|
|
43
48
|
<a
|
|
44
|
-
v-if="external || !
|
|
49
|
+
v-if="external || !internal"
|
|
45
50
|
:href="href"
|
|
46
51
|
:target="target ?? (external ? '_blank' : undefined)"
|
|
47
52
|
:rel="external ? 'noopener' : undefined"
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { DuxtResolvedSource } from '../../sources-resolve';
|
|
2
|
+
import { searchSources } from '../utils/search-scope';
|
|
1
3
|
import type { DuxtSearchSection } from '@duxt/composables/useFuzzySearch';
|
|
2
4
|
|
|
3
5
|
/** A hit, with the source it came out of. */
|
|
@@ -22,18 +24,19 @@ export interface DuxtSearchHit extends DuxtSearchSection {
|
|
|
22
24
|
* The source being read goes first in each round, because a reader searching
|
|
23
25
|
* inside a project usually means that project.
|
|
24
26
|
*
|
|
25
|
-
* ONE VERSION PER
|
|
27
|
+
* ONE VERSION PER ARTEFACT. Searching every version returns each page as many
|
|
26
28
|
* times as there are versions, which buries the answer under its own history.
|
|
27
|
-
*
|
|
29
|
+
* Each docs tree and generated declaration contributes its current or default edition.
|
|
28
30
|
*/
|
|
29
31
|
export function useDuxtSearch() {
|
|
30
32
|
const duxt = useDuxtConfig();
|
|
31
33
|
const { source } = useDuxtCollection();
|
|
34
|
+
const { locale, fallbackLocale } = useI18n();
|
|
32
35
|
|
|
33
36
|
const sources = computed(() => duxt.resolvedSources ?? []);
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
|
-
* The collections
|
|
39
|
+
* The available collections; only selected editions are initialized.
|
|
37
40
|
*
|
|
38
41
|
* Fixed at setup, deliberately: each one needs its own `useSearchCollection`,
|
|
39
42
|
* and a composable list that grows with the route is not a thing. The
|
|
@@ -79,38 +82,54 @@ export function useDuxtSearch() {
|
|
|
79
82
|
}).search
|
|
80
83
|
}));
|
|
81
84
|
|
|
82
|
-
/**
|
|
83
|
-
const active = computed(() =>
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
85
|
+
/** Selected editions, ordered by artefact and then preferred language. */
|
|
86
|
+
const active = computed(() =>
|
|
87
|
+
searchSources(
|
|
88
|
+
searchable,
|
|
89
|
+
source.value,
|
|
90
|
+
locale.value,
|
|
91
|
+
fallbackLocale.value as string | string[] | undefined
|
|
92
|
+
)
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const pagePaths = new Map<string, Promise<Set<string>>>();
|
|
96
|
+
function paths(collection: string) {
|
|
97
|
+
let pending = pagePaths.get(collection);
|
|
98
|
+
if (!pending) {
|
|
99
|
+
pending = queryCollection(collection as DuxtCollectionArg)
|
|
100
|
+
.select('path')
|
|
101
|
+
.all()
|
|
102
|
+
.then((pages) => new Set(pages.map((page) => page.path)))
|
|
103
|
+
.catch((error) => {
|
|
104
|
+
pagePaths.delete(collection);
|
|
105
|
+
throw error;
|
|
106
|
+
});
|
|
107
|
+
pagePaths.set(collection, pending);
|
|
100
108
|
}
|
|
109
|
+
return pending;
|
|
110
|
+
}
|
|
101
111
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
112
|
+
// A fallback may answer only for a PAGE absent from every better language,
|
|
113
|
+
// even when a heading or the search term exists only in the original.
|
|
114
|
+
async function visiblePages(wanted: DuxtResolvedSource[]) {
|
|
115
|
+
return Promise.all(
|
|
116
|
+
wanted.map(async (entry, index) => {
|
|
117
|
+
const preferred = wanted
|
|
118
|
+
.slice(0, index)
|
|
119
|
+
.filter((other) => other.prefix === entry.prefix);
|
|
120
|
+
return new Set(
|
|
121
|
+
(
|
|
122
|
+
await Promise.all(preferred.map((other) => paths(other.collection)))
|
|
123
|
+
).flatMap((pages) => [...pages])
|
|
124
|
+
);
|
|
125
|
+
})
|
|
109
126
|
);
|
|
110
|
-
}
|
|
127
|
+
}
|
|
111
128
|
|
|
112
129
|
/** Only worth a badge when there is more than one thing to tell apart. */
|
|
113
|
-
const labelled = computed(
|
|
130
|
+
const labelled = computed(
|
|
131
|
+
() => new Set(active.value.map((entry) => entry.prefix)).size > 1
|
|
132
|
+
);
|
|
114
133
|
|
|
115
134
|
const labelOf = (entry: (typeof searchable)[number]) =>
|
|
116
135
|
[entry.repo, entry.version].filter(Boolean).join(' ') ||
|
|
@@ -119,8 +138,8 @@ export function useDuxtSearch() {
|
|
|
119
138
|
|
|
120
139
|
async function init() {
|
|
121
140
|
await Promise.all(
|
|
122
|
-
|
|
123
|
-
.
|
|
141
|
+
active.value
|
|
142
|
+
.map((entry) => searches.find((search) => search.entry === entry)!)
|
|
124
143
|
.filter((search) => search.status.value === 'idle')
|
|
125
144
|
.map((search) => search.init())
|
|
126
145
|
);
|
|
@@ -153,17 +172,41 @@ export function useDuxtSearch() {
|
|
|
153
172
|
term: string,
|
|
154
173
|
limit = 20
|
|
155
174
|
): Promise<{ hits: DuxtSearchHit[]; approximate: boolean }> {
|
|
156
|
-
if (!term.trim()) return { hits: [], approximate: false };
|
|
175
|
+
if (!term.trim() || limit <= 0) return { hits: [], approximate: false };
|
|
157
176
|
|
|
158
177
|
const wanted = active.value;
|
|
178
|
+
const hidden = await visiblePages(wanted);
|
|
179
|
+
async function visible(
|
|
180
|
+
query: (requested: number) => Promise<DuxtSearchSection[]>,
|
|
181
|
+
index: number
|
|
182
|
+
) {
|
|
183
|
+
let requested = limit;
|
|
184
|
+
while (true) {
|
|
185
|
+
const hits = await query(requested);
|
|
186
|
+
const shown = hits.filter(
|
|
187
|
+
(hit) => !hidden[index]!.has(hit.id.split('#')[0]!)
|
|
188
|
+
);
|
|
189
|
+
if (
|
|
190
|
+
shown.length >= limit ||
|
|
191
|
+
hits.length < requested ||
|
|
192
|
+
!hidden[index]!.size
|
|
193
|
+
)
|
|
194
|
+
return shown.slice(0, limit);
|
|
195
|
+
requested *= 2;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
159
198
|
|
|
160
199
|
const lists = await Promise.all(
|
|
161
|
-
|
|
162
|
-
.
|
|
163
|
-
.map(async (entry) => {
|
|
164
|
-
const hits =
|
|
165
|
-
|
|
166
|
-
|
|
200
|
+
wanted
|
|
201
|
+
.map((source) => searches.find((entry) => entry.entry === source)!)
|
|
202
|
+
.map(async (entry, index) => {
|
|
203
|
+
const hits = await visible(
|
|
204
|
+
async (requested) =>
|
|
205
|
+
(await entry.search(term, {
|
|
206
|
+
limit: requested
|
|
207
|
+
})) as DuxtSearchSection[],
|
|
208
|
+
index
|
|
209
|
+
);
|
|
167
210
|
|
|
168
211
|
return hits.map((hit) => ({
|
|
169
212
|
...hit,
|
|
@@ -184,10 +227,13 @@ export function useDuxtSearch() {
|
|
|
184
227
|
// leaves the reader with an empty box. Fuse gets a second look, over the
|
|
185
228
|
// same sources and merged the same way.
|
|
186
229
|
const fuzzy = await Promise.all(
|
|
187
|
-
|
|
188
|
-
.
|
|
189
|
-
.map(async (entry) => {
|
|
190
|
-
const hits = await
|
|
230
|
+
wanted
|
|
231
|
+
.map((source) => approximate.find((entry) => entry.entry === source)!)
|
|
232
|
+
.map(async (entry, index) => {
|
|
233
|
+
const hits = await visible(
|
|
234
|
+
(requested) => entry.search(term, requested),
|
|
235
|
+
index
|
|
236
|
+
);
|
|
191
237
|
|
|
192
238
|
return hits.map((hit) => ({
|
|
193
239
|
...hit,
|
package/app/types/duxt.d.ts
CHANGED
|
@@ -185,6 +185,13 @@ declare global {
|
|
|
185
185
|
interface DuxtSourceInput {
|
|
186
186
|
/** Folder holding the Markdown, relative to the repository root. */
|
|
187
187
|
path?: string;
|
|
188
|
+
/**
|
|
189
|
+
* Whether this source's Markdown is published as documentation pages.
|
|
190
|
+
*
|
|
191
|
+
* Set `false` for a source that only provides generated sections. It can
|
|
192
|
+
* then share the default URL prefix of a versioned documentation source.
|
|
193
|
+
*/
|
|
194
|
+
content?: boolean;
|
|
188
195
|
/** `owner/name` or a full git URL. Omitted means this repository. */
|
|
189
196
|
repo?: string;
|
|
190
197
|
/** Refs to publish as versions. Omitted means the current checkout. */
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { isInside } from './version-paths';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a documentation path as the reader will see it.
|
|
5
|
+
*
|
|
6
|
+
* Markdown paths normally belong to the source that contains them. `~/` is the
|
|
7
|
+
* explicit escape hatch: it starts at the website root so one source can name
|
|
8
|
+
* a page served by another source without hard-coding its own prefix.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveDocumentationPath(path: string, prefix: string): string {
|
|
11
|
+
if (path.startsWith('~/')) return path.slice(1);
|
|
12
|
+
|
|
13
|
+
return prefix && !isInside(path, prefix) ? `${prefix}${path}` : path;
|
|
14
|
+
}
|
package/app/utils/locale-path.ts
CHANGED
|
@@ -15,7 +15,10 @@ export function stripLocalePrefix(
|
|
|
15
15
|
path: string,
|
|
16
16
|
codes: Iterable<string>
|
|
17
17
|
): string {
|
|
18
|
-
|
|
18
|
+
const documentPath = splitLocalePath(path, codes).path;
|
|
19
|
+
// Static hosts may append a slash; Content paths and prerendered async-data
|
|
20
|
+
// keys do not. Both URLs must reuse the same data during hydration.
|
|
21
|
+
return documentPath.replace(/\/+$/, '') || (documentPath ? '/' : '');
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
/**
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import type { ContentNavigationItem } from '@nuxt/content';
|
|
2
2
|
|
|
3
|
+
/** Actual pages, including dotted version paths; folder wrappers are not pages. */
|
|
4
|
+
export function navigationPagePaths(items: ContentNavigationItem[]): string[] {
|
|
5
|
+
const paths = new Set<string>();
|
|
6
|
+
for (const item of items) {
|
|
7
|
+
if (item.children?.length) {
|
|
8
|
+
for (const path of navigationPagePaths(item.children)) paths.add(path);
|
|
9
|
+
} else {
|
|
10
|
+
paths.add(item.path);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return [...paths];
|
|
14
|
+
}
|
|
15
|
+
|
|
3
16
|
/** Find a node by path, anywhere in the tree. */
|
|
4
17
|
export function findByPath(
|
|
5
18
|
items: ContentNavigationItem[],
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sourcesForRoute,
|
|
3
|
+
type DuxtResolvedSource
|
|
4
|
+
} from '../../sources-resolve';
|
|
5
|
+
|
|
6
|
+
/** Select the editions participating in site-wide search. */
|
|
7
|
+
export function searchSources(
|
|
8
|
+
sources: DuxtResolvedSource[],
|
|
9
|
+
current: DuxtResolvedSource | undefined,
|
|
10
|
+
locale?: string,
|
|
11
|
+
fallbackLocale?: string | string[]
|
|
12
|
+
): DuxtResolvedSource[] {
|
|
13
|
+
const byArtefact = new Map<string, DuxtResolvedSource>();
|
|
14
|
+
for (const entry of sources) {
|
|
15
|
+
const key = JSON.stringify([entry.repo, entry.generated?.declaration]);
|
|
16
|
+
const chosen = byArtefact.get(key);
|
|
17
|
+
if (entry.collection === current?.collection) {
|
|
18
|
+
byArtefact.set(key, entry);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (chosen?.collection === current?.collection) continue;
|
|
22
|
+
if (!chosen || (entry.isDefault && !chosen.isDefault))
|
|
23
|
+
byArtefact.set(key, entry);
|
|
24
|
+
}
|
|
25
|
+
return [...byArtefact.values()]
|
|
26
|
+
.sort((a, b) =>
|
|
27
|
+
a.collection === current?.collection
|
|
28
|
+
? -1
|
|
29
|
+
: b.collection === current?.collection
|
|
30
|
+
? 1
|
|
31
|
+
: 0
|
|
32
|
+
)
|
|
33
|
+
.flatMap((edition) =>
|
|
34
|
+
sourcesForRoute(
|
|
35
|
+
edition.prefix,
|
|
36
|
+
locale ?? current?.locale,
|
|
37
|
+
sources.filter((entry) => entry.prefix === edition.prefix),
|
|
38
|
+
fallbackLocale
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -45,6 +45,8 @@ export function versionChoices(
|
|
|
45
45
|
// from the URL segment.
|
|
46
46
|
if (configured?.length) return configured;
|
|
47
47
|
|
|
48
|
+
const editions = new Set<string>();
|
|
49
|
+
|
|
48
50
|
return sources
|
|
49
51
|
.filter(
|
|
50
52
|
(source) =>
|
|
@@ -52,6 +54,14 @@ export function versionChoices(
|
|
|
52
54
|
source.repo === current?.repo &&
|
|
53
55
|
sameArtefact(source, current)
|
|
54
56
|
)
|
|
57
|
+
.filter((source) => {
|
|
58
|
+
// Repository and artefact are scoped above; translations share the
|
|
59
|
+
// edition's version and target, while keeping separate collections.
|
|
60
|
+
const edition = JSON.stringify([source.version, source.prefix || '/']);
|
|
61
|
+
if (editions.has(edition)) return false;
|
|
62
|
+
editions.add(edition);
|
|
63
|
+
return true;
|
|
64
|
+
})
|
|
55
65
|
.map((source) => ({
|
|
56
66
|
label: source.version!,
|
|
57
67
|
to: source.prefix || '/',
|
package/nuxt.config.ts
CHANGED
|
@@ -376,10 +376,13 @@ export default defineNuxtConfig({
|
|
|
376
376
|
|
|
377
377
|
/**
|
|
378
378
|
* nuxt-seo-utils, whose defaults are written for a site that sets no head
|
|
379
|
-
* tags of its own.
|
|
380
|
-
* layer cannot design away.
|
|
379
|
+
* tags of its own. These defaults need adjusting for this layer's rendering.
|
|
381
380
|
*/
|
|
382
381
|
seo: {
|
|
382
|
+
// MDC hydrates its Shiki <style> text from the Content payload. Rewriting
|
|
383
|
+
// only the prerendered HTML makes that text differ on the client.
|
|
384
|
+
minify: false,
|
|
385
|
+
|
|
383
386
|
/**
|
|
384
387
|
* A LOCALE PREFIX IS CASE-SENSITIVE. i18n routes this site's locales under
|
|
385
388
|
* their own codes — `/de-DE/guides`, `/pt-BR/guides` — and lowercasing a
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://www.schemastore.org/package.json",
|
|
3
3
|
"name": "@kirchdev/duxt",
|
|
4
|
-
"version": "0.1.1-dev-main.
|
|
4
|
+
"version": "0.1.1-dev-main.30-1e18e2e",
|
|
5
5
|
"description": "Nuxt documentation layer on Content v3 — multi-repo, versioned docs from one compact source list.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"content-v3",
|
|
@@ -238,7 +238,7 @@
|
|
|
238
238
|
<p class="hint">The build validator, run against what the site is serving now.</p>
|
|
239
239
|
<div class="stats"><div class="stat "><b>34</b><span>pages checked</span></div><div class="stat "><b>6</b><span>collections</span></div><div class="stat ok"><b>0</b><span>errors</span></div><div class="stat warn"><b>5</b><span>warnings</span></div></div><h2>Warnings</h2><div class="scroll"><table>
|
|
240
240
|
<thead><tr><th scope="col"></th><th scope="col">Warning</th></tr></thead>
|
|
241
|
-
<tbody><tr><td><span class="tag warn">warn</span></td><td>the generated section "Releases" (CHANGELOG.md): the heading "1.3 (Feb 2026)" on line 41 reads like a release but does not parse as one, so it became part of the release above it instead of a page of its own. A release heading is a version, optionally followed by a date in parentheses: `## 1.4.0 (2024-02-01)`.</td></tr><tr><td><span class="tag warn">warn</span></td><td><a class="file" href="/__nuxt_devtools__/open-in-editor?file=%2Fsrv%2Facme-docs%2Fdocs_sdk%2F3.reference%2F2.errors.md" target="_blank" title="/srv/acme-docs/docs_sdk/3.reference/2.errors.md">"docs_sdk/3.reference/2.errors.md"</a> has no title and no description.</td></tr><tr><td><span class="tag warn">warn</span></td><td><a class="file" href="/__nuxt_devtools__/open-in-editor?file=%2Fsrv%2Facme-docs%2Fdocs_de_sdk%2Fde%2F3.reference%2F2.errors.md" target="_blank" title="/srv/acme-docs/docs_de_sdk/de/3.reference/2.errors.md">"docs_de_sdk/de/3.reference/2.errors.md"</a> has no title and no description.</td></tr><tr><td><span class="tag warn">warn</span></td><td><a class="file" href="/__nuxt_devtools__/open-in-editor?file=%2Fsrv%2Facme-docs%2Fdocs_sdk_v1_9%2F3.reference%2F2.errors.md" target="_blank" title="/srv/acme-docs/docs_sdk_v1_9/3.reference/2.errors.md">"docs_sdk_v1_9/3.reference/2.errors.md"</a> has no title and no description.</td></tr><tr><td><span class="tag warn">warn</span></td><td><a class="file" href="/__nuxt_devtools__/open-in-editor?file=%2Fsrv%2Facme-docs%2Fdocs_sdk%2F1.getting-started%2Findex.md" target="_blank" title="/srv/acme-docs/docs_sdk/1.getting-started/index.md">"docs_sdk/1.getting-started/index.md"</a> links to "/guides/cacheing", which no page serves.</td></tr></tbody>
|
|
241
|
+
<tbody><tr><td><span class="tag warn">warn</span></td><td>the generated section "Releases" (CHANGELOG.md): the heading "1.3 (Feb 2026)" on line 41 reads like a release but does not parse as one, so it became part of the release above it instead of a page of its own. A release heading is a version, optionally followed by a date in parentheses: `## 1.4.0 (2024-02-01)`.</td></tr><tr><td><span class="tag warn">warn</span></td><td><a class="file" href="/__nuxt_devtools__/open-in-editor?file=%2Fsrv%2Facme-docs%2Fdocs_sdk%2F3.reference%2F2.errors.md" target="_blank" title="/srv/acme-docs/docs_sdk/3.reference/2.errors.md">"docs_sdk/3.reference/2.errors.md"</a> has no title and no description.</td></tr><tr><td><span class="tag warn">warn</span></td><td><a class="file" href="/__nuxt_devtools__/open-in-editor?file=%2Fsrv%2Facme-docs%2Fdocs_de_sdk%2Fde%2F3.reference%2F2.errors.md" target="_blank" title="/srv/acme-docs/docs_de_sdk/de/3.reference/2.errors.md">"docs_de_sdk/de/3.reference/2.errors.md"</a> has no title and no description.</td></tr><tr><td><span class="tag warn">warn</span></td><td><a class="file" href="/__nuxt_devtools__/open-in-editor?file=%2Fsrv%2Facme-docs%2Fdocs_sdk_v1_9%2F3.reference%2F2.errors.md" target="_blank" title="/srv/acme-docs/docs_sdk_v1_9/3.reference/2.errors.md">"docs_sdk_v1_9/3.reference/2.errors.md"</a> has no title and no description.</td></tr><tr><td><span class="tag warn">warn</span></td><td><a class="file" href="/__nuxt_devtools__/open-in-editor?file=%2Fsrv%2Facme-docs%2Fdocs_sdk%2F1.getting-started%2Findex.md" target="_blank" title="/srv/acme-docs/docs_sdk/1.getting-started/index.md">"docs_sdk/1.getting-started/index.md"</a> links to "/sdk/guides/cacheing", which no page serves.</td></tr></tbody>
|
|
242
242
|
</table></div><h2>Translations</h2><ul class="steps"><li><strong>de under "/sdk": 6/8 pages</strong></li><li class="dim" style="margin-left:1.5rem"><a class="file" href="/__nuxt_devtools__/open-in-editor?file=%2Fsrv%2Facme-docs%2Fdocs_sdk%2F2.guides%2F2.caching.md" target="_blank" title="/srv/acme-docs/docs_sdk/2.guides/2.caching.md">"docs_sdk/2.guides/2.caching.md"</a> has no de translation.</li><li class="dim" style="margin-left:1.5rem"><a class="file" href="/__nuxt_devtools__/open-in-editor?file=%2Fsrv%2Facme-docs%2Fdocs_sdk%2F4.changelog.md" target="_blank" title="/srv/acme-docs/docs_sdk/4.changelog.md">"docs_sdk/4.changelog.md"</a> has no de translation.</li><li><strong>de under "/sdk/v1.9": 5/7 pages</strong></li><li class="dim" style="margin-left:1.5rem"><a class="file" href="/__nuxt_devtools__/open-in-editor?file=%2Fsrv%2Facme-docs%2Fdocs_sdk_v1_9%2F3.reference%2F2.errors.md" target="_blank" title="/srv/acme-docs/docs_sdk_v1_9/3.reference/2.errors.md">"docs_sdk_v1_9/3.reference/2.errors.md"</a> has no de translation.</li><li class="dim" style="margin-left:1.5rem"><a class="file" href="/__nuxt_devtools__/open-in-editor?file=%2Fsrv%2Facme-docs%2Fdocs_sdk_v1_9%2F4.changelog.md" target="_blank" title="/srv/acme-docs/docs_sdk_v1_9/4.changelog.md">"docs_sdk_v1_9/4.changelog.md"</a> has no de translation.</li></ul>
|
|
243
243
|
</main>
|
|
244
244
|
<script>
|
|
@@ -353,6 +353,7 @@
|
|
|
353
353
|
<span class="j-key">"repository"</span>: <span class="j-str">"acme/cli"</span>,
|
|
354
354
|
<span class="j-key">"repositoryUrl"</span>: <span class="j-str">"https://github.com/acme/cli"</span>,
|
|
355
355
|
<span class="j-key">"path"</span>: <span class="j-str">"docs"</span>,
|
|
356
|
+
<span class="j-key">"locale"</span>: <span class="j-str">"en"</span>,
|
|
356
357
|
<span class="j-key">"isDefaultLocale"</span>: <span class="j-word">true</span>,
|
|
357
358
|
<span class="j-key">"status"</span>: <span class="j-str">"current"</span>,
|
|
358
359
|
<span class="j-key">"history"</span>: <span class="j-word">false</span>
|
package/sections-changelog.ts
CHANGED
|
@@ -192,13 +192,15 @@ function parseChangelog(
|
|
|
192
192
|
context: DuxtSectionContext
|
|
193
193
|
): DuxtSectionPage[] {
|
|
194
194
|
const lines = artefact.split(/\r?\n/);
|
|
195
|
-
|
|
196
|
-
if (granularityOf(context.options) === 'flat') return [flat(lines, context)];
|
|
197
|
-
|
|
198
195
|
const headings = headingsOf(lines);
|
|
199
|
-
|
|
200
196
|
const starts = headings.filter((heading) => release(heading.text));
|
|
201
197
|
|
|
198
|
+
const newest = starts[0] ? release(starts[0].text)?.version : undefined;
|
|
199
|
+
|
|
200
|
+
if (granularityOf(context.options) === 'flat') {
|
|
201
|
+
return [flat(lines, context, newest)];
|
|
202
|
+
}
|
|
203
|
+
|
|
202
204
|
reportNearMisses(headings, starts, context);
|
|
203
205
|
const releases: Release[] = starts.map((heading, index) => {
|
|
204
206
|
const parsed = release(heading.text)!;
|
|
@@ -236,11 +238,15 @@ function parseChangelog(
|
|
|
236
238
|
* exactly where the release tool put it — which is what "unchanged" has to
|
|
237
239
|
* mean, or the mode is not the escape hatch it exists to be.
|
|
238
240
|
*/
|
|
239
|
-
function flat(
|
|
241
|
+
function flat(
|
|
242
|
+
lines: string[],
|
|
243
|
+
context: DuxtSectionContext,
|
|
244
|
+
newest?: string
|
|
245
|
+
): DuxtSectionPage {
|
|
240
246
|
return {
|
|
241
247
|
file: 'index.md',
|
|
242
248
|
body: [
|
|
243
|
-
frontmatter({ title: context.label }),
|
|
249
|
+
frontmatter({ title: context.label, release: newest }),
|
|
244
250
|
'',
|
|
245
251
|
...trim(withoutTitle(lines)),
|
|
246
252
|
''
|
|
@@ -437,7 +443,11 @@ function index(
|
|
|
437
443
|
return {
|
|
438
444
|
file: 'index.md',
|
|
439
445
|
body: [
|
|
440
|
-
|
|
446
|
+
// The header stays outside this component, so it cannot read the MDC
|
|
447
|
+
// props below. Keep the newest release beside the page title instead:
|
|
448
|
+
// Content exposes frontmatter on the page query that `DuxtVersion` uses,
|
|
449
|
+
// and parsing the file again there would make the two answers drift.
|
|
450
|
+
frontmatter({ title: context.label, release: releases[0]?.version }),
|
|
441
451
|
'',
|
|
442
452
|
// NO `<h1>` OF ITS OWN. The page draws the docs header — breadcrumb,
|
|
443
453
|
// title, description, the copy control beside it — for exactly the
|
package/sections-resolve.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
DuxtSourcesOptions
|
|
23
23
|
} from './sources-resolve';
|
|
24
24
|
import {
|
|
25
|
+
assertCollectionIdentities,
|
|
25
26
|
expandSources,
|
|
26
27
|
identifier,
|
|
27
28
|
resolveSources,
|
|
@@ -367,10 +368,16 @@ export function resolveGeneratedSections(
|
|
|
367
368
|
// Claimed per locale, exactly as `resolveSources` claims its own prefixes:
|
|
368
369
|
// two languages serving one prefix is not a collision, it is the point.
|
|
369
370
|
const taken = new Map<string, string>(
|
|
370
|
-
resolved.
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
371
|
+
resolved.flatMap((entry, index) =>
|
|
372
|
+
expanded[index]!.source.content === false
|
|
373
|
+
? []
|
|
374
|
+
: [
|
|
375
|
+
[
|
|
376
|
+
`${entry.locale ?? ''}|${entry.prefix}`,
|
|
377
|
+
`the documentation at "${entry.prefix || '/'}"`
|
|
378
|
+
] as [string, string]
|
|
379
|
+
]
|
|
380
|
+
)
|
|
374
381
|
);
|
|
375
382
|
|
|
376
383
|
// Counted over the whole list rather than per source, so the identity is
|
|
@@ -539,6 +546,10 @@ export function resolveGeneratedSections(
|
|
|
539
546
|
}
|
|
540
547
|
});
|
|
541
548
|
|
|
549
|
+
assertCollectionIdentities([
|
|
550
|
+
...resolved.filter((_, index) => expanded[index]!.source.content !== false),
|
|
551
|
+
...generated
|
|
552
|
+
]);
|
|
542
553
|
return generated;
|
|
543
554
|
}
|
|
544
555
|
|
|
@@ -704,8 +715,11 @@ export function duxtManifest(
|
|
|
704
715
|
options: DuxtSourcesOptions = {},
|
|
705
716
|
types: DuxtSectionTypes = duxtSectionTypes()
|
|
706
717
|
): DuxtResolvedSource[] {
|
|
718
|
+
const resolved = resolveSources(sources, options);
|
|
719
|
+
const expanded = expandSources(sources, options);
|
|
720
|
+
|
|
707
721
|
return [
|
|
708
|
-
...
|
|
722
|
+
...resolved.filter((_, index) => expanded[index]!.source.content !== false),
|
|
709
723
|
...resolveGeneratedSections(sources, options, types)
|
|
710
724
|
];
|
|
711
725
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { queryCollection } from '@nuxt/content/nitro';
|
|
2
|
+
import { defineEventHandler, setHeader } from 'h3';
|
|
3
|
+
import { splitLocalePath } from '../../app/utils/locale-path';
|
|
4
|
+
import { sourcesForRoute } from '../../sources-resolve';
|
|
2
5
|
import { duxtDefaults, mergeDuxtConfig } from '../../app/utils/duxt-config';
|
|
3
6
|
import { stripFrontmatter } from '../utils/duxt-server-text';
|
|
4
7
|
|
|
@@ -21,31 +24,42 @@ export default defineEventHandler(async (event) => {
|
|
|
21
24
|
const appConfig = useAppConfig() as { duxt?: Partial<DuxtConfig> };
|
|
22
25
|
const duxt = mergeDuxtConfig(appConfig.duxt, duxtDefaults);
|
|
23
26
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
const i18n = (
|
|
28
|
+
useRuntimeConfig(event).public as {
|
|
29
|
+
i18n?: {
|
|
30
|
+
locales?: ({ code: string } | string)[];
|
|
31
|
+
defaultLocale?: string;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
).i18n;
|
|
35
|
+
const { path: wanted, locale } = splitLocalePath(
|
|
36
|
+
path.slice(0, -'.md'.length),
|
|
37
|
+
(i18n?.locales ?? []).map((entry) =>
|
|
38
|
+
typeof entry === 'string' ? entry : entry.code
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
// Nuxt i18n loads the consumer's merged Vue I18n config in its request hook.
|
|
42
|
+
// Browser-language detection's fallback is a different policy.
|
|
43
|
+
const fallbackLocale = event.context.nuxtI18n?.vueI18nOptions
|
|
44
|
+
?.fallbackLocale as string | string[] | undefined;
|
|
45
|
+
const sources = sourcesForRoute(
|
|
46
|
+
wanted,
|
|
47
|
+
locale ?? i18n?.defaultLocale,
|
|
48
|
+
duxt.resolvedSources ?? [],
|
|
49
|
+
fallbackLocale
|
|
30
50
|
);
|
|
31
51
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
event,
|
|
44
|
-
source.collection as Parameters<typeof queryCollection>[1]
|
|
45
|
-
)
|
|
46
|
-
.path(wanted)
|
|
47
|
-
.select('title', 'rawbody')
|
|
48
|
-
.first();
|
|
52
|
+
let page;
|
|
53
|
+
for (const source of sources) {
|
|
54
|
+
page = await queryCollection(
|
|
55
|
+
event,
|
|
56
|
+
source.collection as Parameters<typeof queryCollection>[1]
|
|
57
|
+
)
|
|
58
|
+
.path(wanted)
|
|
59
|
+
.select('title', 'rawbody')
|
|
60
|
+
.first();
|
|
61
|
+
if (page) break;
|
|
62
|
+
}
|
|
49
63
|
|
|
50
64
|
// No page is not this middleware's error to raise: falling through lets the
|
|
51
65
|
// app answer with its own 404, which knows how to suggest a near miss.
|
|
@@ -57,30 +71,3 @@ export default defineEventHandler(async (event) => {
|
|
|
57
71
|
setHeader(event, 'content-type', 'text/markdown; charset=utf-8');
|
|
58
72
|
return stripFrontmatter(body).trim();
|
|
59
73
|
});
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* The locale segment, off.
|
|
63
|
-
*
|
|
64
|
-
* The browser is on `/de-DE/guide/deploying`; the page is `/guide/deploying`.
|
|
65
|
-
* Same distinction `stripLocalePrefix` makes in the app, made again here
|
|
66
|
-
* because Nitro has no i18n composable to ask.
|
|
67
|
-
*/
|
|
68
|
-
function stripLocale(path: string): string {
|
|
69
|
-
const first = path.split('/')[1];
|
|
70
|
-
if (!first) return path;
|
|
71
|
-
|
|
72
|
-
const locales = (
|
|
73
|
-
useRuntimeConfig().public as {
|
|
74
|
-
i18n?: { locales?: ({ code: string } | string)[] };
|
|
75
|
-
}
|
|
76
|
-
).i18n?.locales;
|
|
77
|
-
|
|
78
|
-
const codes = (locales ?? []).map((locale) =>
|
|
79
|
-
typeof locale === 'string' ? locale : locale.code
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
if (!codes.includes(first)) return path;
|
|
83
|
-
|
|
84
|
-
const rest = path.slice(first.length + 1);
|
|
85
|
-
return rest.startsWith('/') ? rest : rest || '/';
|
|
86
|
-
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { llmsPages } from '../utils/llms-pages';
|
|
2
2
|
// Imported by path, not auto-import: app/ belongs to the Vue app, and Nitro
|
|
3
3
|
// does not see its utils. The defaults have to come from the same file the
|
|
4
4
|
// pages use, or the two descriptions drift apart.
|
|
@@ -27,19 +27,7 @@ export default defineEventHandler(async (event) => {
|
|
|
27
27
|
mergeDuxtConfig(appConfig.duxt, duxtDefaults)
|
|
28
28
|
);
|
|
29
29
|
|
|
30
|
-
const
|
|
31
|
-
? duxt.resolvedSources.map((source) => source.collection)
|
|
32
|
-
: ['docs'];
|
|
33
|
-
|
|
34
|
-
const pages = (
|
|
35
|
-
await Promise.all(
|
|
36
|
-
collections.map((name) =>
|
|
37
|
-
queryCollection(event, name as Parameters<typeof queryCollection>[1])
|
|
38
|
-
.select('path', 'title', 'description', 'rawbody')
|
|
39
|
-
.all()
|
|
40
|
-
)
|
|
41
|
-
)
|
|
42
|
-
).flat();
|
|
30
|
+
const pages = await llmsPages(event, duxt.resolvedSources);
|
|
43
31
|
|
|
44
32
|
const origin = getRequestURL(event).origin;
|
|
45
33
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { llmsPages } from '../utils/llms-pages';
|
|
2
2
|
// Imported by path, not auto-import: app/ belongs to the Vue app, and Nitro
|
|
3
3
|
// does not see its utils. The defaults have to come from the same file the
|
|
4
4
|
// pages use, or the two descriptions drift apart.
|
|
@@ -22,21 +22,7 @@ export default defineEventHandler(async (event) => {
|
|
|
22
22
|
|
|
23
23
|
// Every collection the manifest names, not just `docs`: a versioned site
|
|
24
24
|
// has none by that name, and llms.txt is the whole site's index.
|
|
25
|
-
const
|
|
26
|
-
? duxt.resolvedSources.map((source) => source.collection)
|
|
27
|
-
: ['docs'];
|
|
28
|
-
|
|
29
|
-
const pages = (
|
|
30
|
-
await Promise.all(
|
|
31
|
-
collections.map((name) =>
|
|
32
|
-
// Cast from Content's own signature: the collection name is data
|
|
33
|
-
// from the manifest, and the key union is generated per site.
|
|
34
|
-
queryCollection(event, name as Parameters<typeof queryCollection>[1])
|
|
35
|
-
.select('path', 'title', 'description')
|
|
36
|
-
.all()
|
|
37
|
-
)
|
|
38
|
-
)
|
|
39
|
-
).flat();
|
|
25
|
+
const pages = await llmsPages(event, duxt.resolvedSources);
|
|
40
26
|
|
|
41
27
|
const origin = getRequestURL(event).origin;
|
|
42
28
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { queryCollection } from '@nuxt/content/nitro';
|
|
2
|
+
import type { H3Event } from 'h3';
|
|
3
|
+
import {
|
|
4
|
+
sourcesForRoute,
|
|
5
|
+
type DuxtResolvedSource
|
|
6
|
+
} from '../../sources-resolve';
|
|
7
|
+
|
|
8
|
+
/** Enumerate public pages, selecting the same collection chain as HTML. */
|
|
9
|
+
export async function llmsPages(
|
|
10
|
+
event: H3Event,
|
|
11
|
+
sources: DuxtResolvedSource[] = []
|
|
12
|
+
) {
|
|
13
|
+
const i18n = (
|
|
14
|
+
useRuntimeConfig(event).public as unknown as {
|
|
15
|
+
i18n?: {
|
|
16
|
+
locales?: (string | { code: string })[];
|
|
17
|
+
defaultLocale?: string;
|
|
18
|
+
strategy?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
).i18n;
|
|
22
|
+
const fallbackLocale = event.context.nuxtI18n?.vueI18nOptions
|
|
23
|
+
?.fallbackLocale as string | string[] | undefined;
|
|
24
|
+
const codes = (i18n?.locales ?? []).map((locale) =>
|
|
25
|
+
typeof locale === 'string' ? locale : locale.code
|
|
26
|
+
);
|
|
27
|
+
const defaultLocale = i18n?.defaultLocale ?? codes[0];
|
|
28
|
+
const strategy = i18n?.strategy ?? 'prefix_except_default';
|
|
29
|
+
const locales =
|
|
30
|
+
strategy === 'no_prefix' || !codes.length ? [defaultLocale] : codes;
|
|
31
|
+
const collections = sources.length
|
|
32
|
+
? [...new Set(sources.map((source) => source.collection))]
|
|
33
|
+
: ['docs'];
|
|
34
|
+
const entries = await Promise.all(
|
|
35
|
+
collections.map(
|
|
36
|
+
async (name) =>
|
|
37
|
+
[
|
|
38
|
+
name,
|
|
39
|
+
await queryCollection(
|
|
40
|
+
event,
|
|
41
|
+
name as Parameters<typeof queryCollection>[1]
|
|
42
|
+
)
|
|
43
|
+
.select('path', 'title', 'description', 'rawbody')
|
|
44
|
+
.all()
|
|
45
|
+
] as const
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
const pagesByCollection = new Map(
|
|
49
|
+
entries.map(([name, pages]) => [
|
|
50
|
+
name,
|
|
51
|
+
new Map(
|
|
52
|
+
pages.filter((page) => page.path).map((page) => [page.path, page])
|
|
53
|
+
)
|
|
54
|
+
])
|
|
55
|
+
);
|
|
56
|
+
const paths = new Set(
|
|
57
|
+
entries.flatMap(([, pages]) =>
|
|
58
|
+
pages.map((page) => page.path).filter(Boolean)
|
|
59
|
+
)
|
|
60
|
+
);
|
|
61
|
+
const result = new Map<string, (typeof entries)[number][1][number]>();
|
|
62
|
+
|
|
63
|
+
for (const locale of locales) {
|
|
64
|
+
for (const path of paths) {
|
|
65
|
+
const chain = sources.length
|
|
66
|
+
? sourcesForRoute(path, locale, sources, fallbackLocale).map(
|
|
67
|
+
(source) => source.collection
|
|
68
|
+
)
|
|
69
|
+
: ['docs'];
|
|
70
|
+
const page = chain
|
|
71
|
+
.map((name) => pagesByCollection.get(name)?.get(path))
|
|
72
|
+
.find(Boolean);
|
|
73
|
+
if (!page) continue;
|
|
74
|
+
const prefixed =
|
|
75
|
+
locale &&
|
|
76
|
+
strategy !== 'no_prefix' &&
|
|
77
|
+
(strategy !== 'prefix_except_default' || locale !== defaultLocale);
|
|
78
|
+
const publicPath = prefixed
|
|
79
|
+
? `/${locale}${path === '/' ? '' : path}`
|
|
80
|
+
: path;
|
|
81
|
+
result.set(publicPath, { ...page, path: publicPath });
|
|
82
|
+
if (strategy === 'prefix_and_default' && locale === defaultLocale)
|
|
83
|
+
result.set(path, { ...page, path });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [...result.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
87
|
+
}
|
package/sources-git.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import type { DuxtSource } from './sources-resolve';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
expandSources,
|
|
5
|
+
isLatestRef,
|
|
6
|
+
newestTag,
|
|
7
|
+
repoUrl
|
|
8
|
+
} from './sources-resolve';
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* Resolve the `latest` shorthand into the tag it means.
|
|
@@ -81,6 +86,8 @@ function tagsOf(repo: string | undefined): string[] {
|
|
|
81
86
|
* resolver itself stays pure and testable — it never learns that git exists.
|
|
82
87
|
*/
|
|
83
88
|
export function resolveLatestRefs(sources: DuxtSource[]): DuxtSource[] {
|
|
89
|
+
// Reject local refs before a missing tag can hide the configuration error.
|
|
90
|
+
expandSources(sources);
|
|
84
91
|
return sources.map((source) => {
|
|
85
92
|
if (!source.refs?.some(isLatestRef)) return source;
|
|
86
93
|
|
package/sources-resolve.ts
CHANGED
|
@@ -12,10 +12,18 @@ import type {
|
|
|
12
12
|
export interface DuxtSource {
|
|
13
13
|
/** Folder holding the Markdown, relative to the repository root. */
|
|
14
14
|
path?: string;
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Whether this source's Markdown is published as documentation pages.
|
|
17
|
+
*
|
|
18
|
+
* A source can carry generated sections without publishing its own tree. That
|
|
19
|
+
* lets a versioned documentation source and a generated artefact share their
|
|
20
|
+
* default URL prefix without declaring two page collections for it.
|
|
21
|
+
*/
|
|
22
|
+
content?: boolean;
|
|
23
|
+
/** `owner/name` or a full git URL. Omitted reads the local checkout. */
|
|
16
24
|
repo?: string;
|
|
17
25
|
/**
|
|
18
|
-
* Refs to publish as versions.
|
|
26
|
+
* Refs to publish as versions. Requires an explicit source or locale repo.
|
|
19
27
|
*
|
|
20
28
|
* A bare string is a branch. A tag has to say so — git keeps the two in
|
|
21
29
|
* separate namespaces, and asking for a tag under refs/heads fails the
|
|
@@ -33,7 +41,8 @@ export interface DuxtSource {
|
|
|
33
41
|
* The DEFAULT locale is the exception: it is the tree in `path` itself, with
|
|
34
42
|
* no folder, so listing it changes nothing. That is what keeps this key
|
|
35
43
|
* additive — a site that adds `locales` does not move the pages it already
|
|
36
|
-
* serves. Which one is the default comes from `defaultLocale`.
|
|
44
|
+
* serves. Which one is the default comes from `defaultLocale`. Omitting
|
|
45
|
+
* `locales` serves that default language too.
|
|
37
46
|
*/
|
|
38
47
|
locales?: DuxtSourceLocale[];
|
|
39
48
|
/**
|
|
@@ -166,7 +175,7 @@ export type DuxtSourceLocale =
|
|
|
166
175
|
path?: string;
|
|
167
176
|
/** A repository of its own — `owner/name` or a git URL. */
|
|
168
177
|
repo?: string;
|
|
169
|
-
/** A ref of its own
|
|
178
|
+
/** A ref of its own; requires an explicit source or locale repo. */
|
|
170
179
|
ref?: DuxtRef;
|
|
171
180
|
};
|
|
172
181
|
|
|
@@ -488,12 +497,16 @@ export function expandSources(
|
|
|
488
497
|
|
|
489
498
|
return sources.flatMap((source) =>
|
|
490
499
|
(source.refs?.length ? source.refs : [undefined]).flatMap((ref) =>
|
|
491
|
-
localesOf(source, ref).map((locale) =>
|
|
492
|
-
source,
|
|
493
|
-
ref
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
500
|
+
localesOf(source, ref).map((locale) => {
|
|
501
|
+
const effective = localeEntry(source, locale, defaultLocale);
|
|
502
|
+
if (!effective.repo && (effective.ref ?? ref)) {
|
|
503
|
+
throw new Error(
|
|
504
|
+
`duxt: source "${source.path ?? 'docs'}" reads the local checkout and cannot select a ref. ` +
|
|
505
|
+
'Set an explicit repo to publish Git refs, or use version folders without refs.'
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
return { source, ref, locale, effective };
|
|
509
|
+
})
|
|
497
510
|
)
|
|
498
511
|
);
|
|
499
512
|
}
|
|
@@ -561,7 +574,7 @@ export function resolveSources(
|
|
|
561
574
|
const name = effectiveRef ? refName(effectiveRef) : source.version;
|
|
562
575
|
const label =
|
|
563
576
|
(ref && typeof ref === 'object' ? ref.label : undefined) ?? source.label;
|
|
564
|
-
const code = locale ? localeCode(locale) :
|
|
577
|
+
const code = locale ? localeCode(locale) : defaultLocale;
|
|
565
578
|
const isDefaultLocale = !code || code === defaultLocale;
|
|
566
579
|
const version = name ? slugify(label ?? name) : undefined;
|
|
567
580
|
const isDefault = !name || name === defaultRef;
|
|
@@ -584,21 +597,23 @@ export function resolveSources(
|
|
|
584
597
|
|
|
585
598
|
// Claimed per locale: two languages serving one prefix is not a collision,
|
|
586
599
|
// it is the point.
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
`
|
|
595
|
-
|
|
600
|
+
if (source.content !== false) {
|
|
601
|
+
const claim = `${code ?? ''}|${prefix}`;
|
|
602
|
+
const previous = taken.get(claim);
|
|
603
|
+
if (previous) {
|
|
604
|
+
// The one ambiguity the build-time decision leaves: a docs folder named
|
|
605
|
+
// like a repository or a version. Rejected rather than resolved silently.
|
|
606
|
+
throw new Error(
|
|
607
|
+
`duxt: two sources resolve to the same URL prefix "${prefix || '/'}" ` +
|
|
608
|
+
`(${previous} and ${source.repo ?? 'this repository'}${ref ? `@${ref}` : ''}). ` +
|
|
609
|
+
'Give one of them a `slug` or a `label`.'
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
taken.set(
|
|
613
|
+
claim,
|
|
614
|
+
`${source.repo ?? 'this repository'}${name ? `@${name}` : ''}`
|
|
596
615
|
);
|
|
597
616
|
}
|
|
598
|
-
taken.set(
|
|
599
|
-
claim,
|
|
600
|
-
`${source.repo ?? 'this repository'}${name ? `@${name}` : ''}`
|
|
601
|
-
);
|
|
602
617
|
|
|
603
618
|
resolved.push({
|
|
604
619
|
collection,
|
|
@@ -631,6 +646,9 @@ export function resolveSources(
|
|
|
631
646
|
});
|
|
632
647
|
}
|
|
633
648
|
|
|
649
|
+
assertCollectionIdentities(
|
|
650
|
+
resolved.filter((_, index) => expanded[index]!.source.content !== false)
|
|
651
|
+
);
|
|
634
652
|
return resolved;
|
|
635
653
|
}
|
|
636
654
|
|
|
@@ -906,3 +924,41 @@ function isInsidePrefix(path: string, prefix: string): boolean {
|
|
|
906
924
|
|
|
907
925
|
return path === prefix || path.startsWith(`${prefix}/`);
|
|
908
926
|
}
|
|
927
|
+
|
|
928
|
+
/** Validate names before any collection map can overwrite a declaration. */
|
|
929
|
+
export function assertCollectionIdentities(
|
|
930
|
+
entries: DuxtResolvedSource[]
|
|
931
|
+
): void {
|
|
932
|
+
const taken = new Map<string, string>();
|
|
933
|
+
const claim = (name: string, declaration: string) => {
|
|
934
|
+
const previous = taken.get(name);
|
|
935
|
+
if (previous !== undefined) {
|
|
936
|
+
throw new Error(
|
|
937
|
+
`duxt: collection "${name}" is claimed by both ${previous} and ${declaration}. ` +
|
|
938
|
+
'Give one declaration a distinct `slug` or `label`.'
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
taken.set(name, declaration);
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
for (const entry of entries) {
|
|
945
|
+
const kind = entry.generated
|
|
946
|
+
? `generated section "${entry.generated.label}"`
|
|
947
|
+
: 'documentation';
|
|
948
|
+
claim(
|
|
949
|
+
entry.collection,
|
|
950
|
+
`${kind} "${entry.path}" at "${entry.prefix || '/'}"` +
|
|
951
|
+
(entry.locale ? ` (locale "${entry.locale}")` : '')
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Shared partials are one declaration per language, even across sources.
|
|
956
|
+
const locales = new Set(
|
|
957
|
+
entries
|
|
958
|
+
.filter((entry) => !entry.generated)
|
|
959
|
+
.map((entry) => (entry.isDefaultLocale ? undefined : entry.locale))
|
|
960
|
+
);
|
|
961
|
+
for (const locale of locales) {
|
|
962
|
+
claim(partialsCollection(locale), `partials for "${locale ?? 'default'}"`);
|
|
963
|
+
}
|
|
964
|
+
}
|
package/sources.ts
CHANGED
|
@@ -37,6 +37,8 @@ export const pageSchema = z.object({
|
|
|
37
37
|
icon: z.string().optional(),
|
|
38
38
|
/** `landing` renders the page without the docs chrome. */
|
|
39
39
|
layout: z.string().optional(),
|
|
40
|
+
/** The latest release of a generated changelog overview. */
|
|
41
|
+
release: z.string().optional(),
|
|
40
42
|
/** false hides the page from the navigation. */
|
|
41
43
|
navigation: z.boolean().optional(),
|
|
42
44
|
/**
|
|
@@ -153,6 +155,7 @@ export function duxtSources(
|
|
|
153
155
|
|
|
154
156
|
resolved.forEach((entry, index) => {
|
|
155
157
|
const { source, ref, effective } = expanded[index]!;
|
|
158
|
+
if (source.content === false) return;
|
|
156
159
|
|
|
157
160
|
// A locale entry may carry a ref of its own — a translation repository
|
|
158
161
|
// that tags on its own schedule.
|
|
@@ -228,7 +231,8 @@ function partialFolders(
|
|
|
228
231
|
const seen = new Set<string>();
|
|
229
232
|
|
|
230
233
|
resolved.forEach((entry, index) => {
|
|
231
|
-
const { effective } = expanded[index]!;
|
|
234
|
+
const { source, effective } = expanded[index]!;
|
|
235
|
+
if (source.content === false) return;
|
|
232
236
|
const key = entry.isDefaultLocale ? undefined : entry.locale;
|
|
233
237
|
|
|
234
238
|
// One entry per REPOSITORY AND FOLDER, not per version: a partial is a
|
package/validate-report.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { reservedSegments } from './sources-resolve';
|
|
12
12
|
import { packageCommandIssues } from './app/utils/package-command';
|
|
13
|
+
import { resolveDocumentationPath } from './app/utils/documentation-link';
|
|
13
14
|
|
|
14
15
|
export interface PageRecord {
|
|
15
16
|
collection: string;
|
|
@@ -203,9 +204,9 @@ export function report(
|
|
|
203
204
|
// verify, and checking it would put the network in the build.
|
|
204
205
|
//
|
|
205
206
|
// Resolved exactly as `ProseA` resolves them at render time: an absolute
|
|
206
|
-
// path written in a page is relative to that page's OWN source
|
|
207
|
-
//
|
|
208
|
-
//
|
|
207
|
+
// path written in a page is relative to that page's OWN source. `~/` is
|
|
208
|
+
// the one explicit website-root form. Checking a normal path bare as a
|
|
209
|
+
// fallback would green-light a rendered 404 from a prefixed source.
|
|
209
210
|
//
|
|
210
211
|
// PER LANGUAGE, and that is not a refinement. Every language of one source
|
|
211
212
|
// serves IDENTICAL content paths — the locale lives in front of the URL,
|
|
@@ -259,9 +260,15 @@ export function report(
|
|
|
259
260
|
|
|
260
261
|
for (const link of page.links) {
|
|
261
262
|
const { href } = link;
|
|
262
|
-
if (
|
|
263
|
+
if (
|
|
264
|
+
!href.startsWith('/') &&
|
|
265
|
+
!href.startsWith('~/') &&
|
|
266
|
+
!href.startsWith('#')
|
|
267
|
+
)
|
|
268
|
+
continue;
|
|
263
269
|
|
|
264
|
-
const [
|
|
270
|
+
const [targetAndQuery, fragment] = href.split('#');
|
|
271
|
+
const [target, query] = targetAndQuery!.split('?');
|
|
265
272
|
|
|
266
273
|
// DECODED, because the two halves are written in different alphabets. A
|
|
267
274
|
// heading's id is the text as it stands — `icônes` — while the link
|
|
@@ -269,20 +276,22 @@ export function report(
|
|
|
269
276
|
// accented anchor on the site as missing and told the author to point at
|
|
270
277
|
// a heading that was already there.
|
|
271
278
|
const anchor = fragment && decodeAnchor(fragment);
|
|
272
|
-
const
|
|
273
|
-
? (
|
|
274
|
-
:
|
|
279
|
+
const renderedTarget = target
|
|
280
|
+
? resolveDocumentationPath(target, prefix)
|
|
281
|
+
: target;
|
|
282
|
+
const renderedHref = `${renderedTarget}${query ? `?${query}` : ''}${fragment ? `#${fragment}` : ''}`;
|
|
283
|
+
const destination = renderedTarget ? resolve(renderedTarget, page) : page;
|
|
275
284
|
|
|
276
|
-
if (
|
|
285
|
+
if (renderedTarget && !destination) {
|
|
277
286
|
warnings.push(
|
|
278
|
-
`"${page.file}" links to "${
|
|
287
|
+
`"${page.file}" links to "${renderedHref}", which no page serves.`
|
|
279
288
|
);
|
|
280
289
|
continue;
|
|
281
290
|
}
|
|
282
291
|
|
|
283
292
|
if (anchor && destination && !destination.anchors.has(anchor)) {
|
|
284
293
|
warnings.push(
|
|
285
|
-
`"${page.file}" links to "${
|
|
294
|
+
`"${page.file}" links to "${renderedHref}", but that page has no "${anchor}" heading.`
|
|
286
295
|
);
|
|
287
296
|
}
|
|
288
297
|
}
|