@kirchdev/duxt 0.1.1-dev-main.13-636388d → 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.
@@ -156,7 +156,14 @@ function current(to?: string) {
156
156
  <UiSheetContent side="left" class="flex w-80 flex-col gap-0 p-0">
157
157
  <UiSheetHeader class="border-b pr-14">
158
158
  <UiSheetTitle class="flex items-center gap-2">
159
- <DuxtBrand />
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>
160
167
 
161
168
  <!-- One version and no choice: a fact about the project, so it
162
169
  sits with the identity as a badge rather than filling a
@@ -328,7 +335,7 @@ function current(to?: string) {
328
335
  </ul>
329
336
  </template>
330
337
 
331
- <template v-if="items.length">
338
+ <template v-if="path !== '/' && items.length">
332
339
  <p
333
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"
334
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
- {{ current?.label ?? duxt.version }}
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
- {{ current?.label ?? duxt.version }}
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
- {{ duxt.version }}
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. A path that already carries the prefix is left
15
- * alone, so a link written the long way round still works.
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 || !href.startsWith('/')) return href;
36
+ if (external.value || !internal.value) return href;
32
37
 
33
38
  const prefix = source.value?.prefix ?? '';
34
- const [path, hash] = href.split('#');
35
- const resolved =
36
- prefix && !isInside(path!, prefix) ? `${prefix}${path}` : path!;
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 || !href?.startsWith('/')"
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 REPOSITORY. Searching every version returns each page as many
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
- * So each repository contributes the version the reader is in, or its default.
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 to search, one per repository.
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
- /** One entry per repository: the version being read, else that repo's default. */
83
- const active = computed(() => {
84
- const current = source.value;
85
- const byRepo = new Map<string, (typeof searchable)[number]>();
86
-
87
- for (const entry of searchable) {
88
- const key = entry.repo ?? '';
89
- const chosen = byRepo.get(key);
90
-
91
- if (entry.collection === current?.collection) {
92
- byRepo.set(key, entry);
93
- continue;
94
- }
95
-
96
- if (chosen?.collection === current?.collection) continue;
97
- if (!chosen || (entry.isDefault && !chosen.isDefault)) {
98
- byRepo.set(key, entry);
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
- // The source being read leads; the rest keep the config's own order.
103
- return [...byRepo.values()].sort((a, b) =>
104
- a.collection === current?.collection
105
- ? -1
106
- : b.collection === current?.collection
107
- ? 1
108
- : 0
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(() => active.value.length > 1);
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
- searches
123
- .filter((search) => active.value.includes(search.entry))
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
- searches
162
- .filter((entry) => wanted.includes(entry.entry))
163
- .map(async (entry) => {
164
- const hits = (await entry.search(term, {
165
- limit
166
- })) as DuxtSearchSection[];
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
- approximate
188
- .filter((entry) => wanted.includes(entry.entry))
189
- .map(async (entry) => {
190
- const hits = await entry.search(term, limit);
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,
@@ -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
+ }
@@ -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/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.13-636388d",
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 &quot;Releases&quot; (CHANGELOG.md): the heading &quot;1.3 (Feb 2026)&quot; 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">&quot;docs_sdk/3.reference/2.errors.md&quot;</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">&quot;docs_de_sdk/de/3.reference/2.errors.md&quot;</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">&quot;docs_sdk_v1_9/3.reference/2.errors.md&quot;</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">&quot;docs_sdk/1.getting-started/index.md&quot;</a> links to &quot;/guides/cacheing&quot;, which no page serves.</td></tr></tbody>
241
+ <tbody><tr><td><span class="tag warn">warn</span></td><td>the generated section &quot;Releases&quot; (CHANGELOG.md): the heading &quot;1.3 (Feb 2026)&quot; 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">&quot;docs_sdk/3.reference/2.errors.md&quot;</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">&quot;docs_de_sdk/de/3.reference/2.errors.md&quot;</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">&quot;docs_sdk_v1_9/3.reference/2.errors.md&quot;</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">&quot;docs_sdk/1.getting-started/index.md&quot;</a> links to &quot;/sdk/guides/cacheing&quot;, which no page serves.</td></tr></tbody>
242
242
  </table></div><h2>Translations</h2><ul class="steps"><li><strong>de under &quot;/sdk&quot;: 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">&quot;docs_sdk/2.guides/2.caching.md&quot;</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">&quot;docs_sdk/4.changelog.md&quot;</a> has no de translation.</li><li><strong>de under &quot;/sdk/v1.9&quot;: 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">&quot;docs_sdk_v1_9/3.reference/2.errors.md&quot;</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">&quot;docs_sdk_v1_9/4.changelog.md&quot;</a> has no de translation.</li></ul>
243
243
  </main>
244
244
  <script>
@@ -353,6 +353,7 @@
353
353
  <span class="j-key">&quot;repository&quot;</span>: <span class="j-str">&quot;acme/cli&quot;</span>,
354
354
  <span class="j-key">&quot;repositoryUrl&quot;</span>: <span class="j-str">&quot;https://github.com/acme/cli&quot;</span>,
355
355
  <span class="j-key">&quot;path&quot;</span>: <span class="j-str">&quot;docs&quot;</span>,
356
+ <span class="j-key">&quot;locale&quot;</span>: <span class="j-str">&quot;en&quot;</span>,
356
357
  <span class="j-key">&quot;isDefaultLocale&quot;</span>: <span class="j-word">true</span>,
357
358
  <span class="j-key">&quot;status&quot;</span>: <span class="j-str">&quot;current&quot;</span>,
358
359
  <span class="j-key">&quot;history&quot;</span>: <span class="j-word">false</span>
@@ -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(lines: string[], context: DuxtSectionContext): DuxtSectionPage {
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
- frontmatter({ title: context.label }),
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
@@ -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.map((entry) => [
371
- `${entry.locale ?? ''}|${entry.prefix}`,
372
- `the documentation at "${entry.prefix || '/'}"`
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
- ...resolveSources(sources, options),
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 wanted = stripLocale(path.slice(0, -'.md'.length));
25
-
26
- // Longest prefix first, exactly as the app resolves a route: `/app/v2` beats
27
- // `/app` on `/app/v2/guide`.
28
- const sources = [...(duxt.resolvedSources ?? [])].sort(
29
- (a, b) => b.prefix.length - a.prefix.length
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
- const source =
33
- sources.find(
34
- (entry) =>
35
- !entry.prefix ||
36
- wanted === entry.prefix ||
37
- wanted.startsWith(`${entry.prefix}/`)
38
- ) ?? sources[0];
39
-
40
- if (!source) return;
41
-
42
- const page = await queryCollection(
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 { queryCollection } from '@nuxt/content/nitro';
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 collections = duxt.resolvedSources?.length
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 { queryCollection } from '@nuxt/content/nitro';
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 collections = duxt.resolvedSources?.length
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 { isLatestRef, newestTag, repoUrl } from './sources-resolve';
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
 
@@ -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
- /** `owner/name` or a full git URL. Omitted means this repository. */
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. Omitted means the current checkout.
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, when the translation is versioned separately. */
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
- locale,
495
- effective: localeEntry(source, locale, defaultLocale)
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) : undefined;
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
- const claim = `${code ?? ''}|${prefix}`;
588
- const previous = taken.get(claim);
589
- if (previous) {
590
- // The one ambiguity the build-time decision leaves: a docs folder named
591
- // like a repository or a version. Rejected rather than resolved silently.
592
- throw new Error(
593
- `duxt: two sources resolve to the same URL prefix "${prefix || '/'}" ` +
594
- `(${previous} and ${source.repo ?? 'this repository'}${ref ? `@${ref}` : ''}). ` +
595
- 'Give one of them a `slug` or a `label`.'
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
@@ -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, so it is
207
- // tried under the source's prefix first and bare second. Checking only the
208
- // bare form reports every correct link on a prefixed site.
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 (!href.startsWith('/') && !href.startsWith('#')) continue;
263
+ if (
264
+ !href.startsWith('/') &&
265
+ !href.startsWith('~/') &&
266
+ !href.startsWith('#')
267
+ )
268
+ continue;
263
269
 
264
- const [target, fragment] = href.split('#');
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 destination = target
273
- ? (resolve(`${prefix}${target}`, page) ?? resolve(target, page))
274
- : page;
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 (target && !destination) {
285
+ if (renderedTarget && !destination) {
277
286
  warnings.push(
278
- `"${page.file}" links to "${href}", which no page serves.`
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 "${href}", but that page has no "${anchor}" heading.`
294
+ `"${page.file}" links to "${renderedHref}", but that page has no "${anchor}" heading.`
286
295
  );
287
296
  }
288
297
  }