@kirchdev/duxt 0.1.1-dev.29-765cae3 → 0.2.1-dev-main.32-677294a

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.
@@ -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>
@@ -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"
@@ -143,6 +143,16 @@ declare global {
143
143
  | 'deprecated'
144
144
  | 'eol';
145
145
 
146
+ /** Lifecycle defaults a source may opt into for its different ref kinds. */
147
+ type DuxtSourceStatusDefaultsInput = {
148
+ /** The moving `latest` shorthand. */
149
+ latest?: DuxtSourceStatusInput;
150
+ /** An explicitly listed tag. */
151
+ tag?: DuxtSourceStatusInput;
152
+ /** A named branch, such as `main`. */
153
+ branch?: DuxtSourceStatusInput;
154
+ };
155
+
146
156
  /**
147
157
  * A branch by name, or a tag stated as one.
148
158
  *
@@ -154,12 +164,16 @@ declare global {
154
164
  | {
155
165
  branch: string;
156
166
  label?: string;
167
+ /** Serve this ref without a version prefix. */
168
+ default?: boolean;
157
169
  status?: DuxtSourceStatusInput;
158
170
  locales?: DuxtSourceLocaleInput[];
159
171
  }
160
172
  | {
161
173
  tag: string;
162
174
  label?: string;
175
+ /** Serve this ref without a version prefix. */
176
+ default?: boolean;
163
177
  status?: DuxtSourceStatusInput;
164
178
  locales?: DuxtSourceLocaleInput[];
165
179
  };
@@ -185,6 +199,13 @@ declare global {
185
199
  interface DuxtSourceInput {
186
200
  /** Folder holding the Markdown, relative to the repository root. */
187
201
  path?: string;
202
+ /**
203
+ * Whether this source's Markdown is published as documentation pages.
204
+ *
205
+ * Set `false` for a source that only provides generated sections. It can
206
+ * then share the default URL prefix of a versioned documentation source.
207
+ */
208
+ content?: boolean;
188
209
  /** `owner/name` or a full git URL. Omitted means this repository. */
189
210
  repo?: string;
190
211
  /** Refs to publish as versions. Omitted means the current checkout. */
@@ -208,8 +229,8 @@ declare global {
208
229
  *
209
230
  * Named here, everything else follows the ref path exactly: the URL segment,
210
231
  * the switcher entry (scoped to the same artefact), the banner and the
211
- * canonical. `sourceOptions.defaultRef` names which of them is served without
212
- * a prefix, whether it is a ref or one of these.
232
+ * canonical. A ref may name itself as the default; otherwise
233
+ * `sourceOptions.defaultRef` names which is served without a prefix.
213
234
  *
214
235
  * Never beside `refs` — a source with both would have to be served at two
215
236
  * prefixes at once, and the resolver says so rather than picking one.
@@ -227,6 +248,8 @@ declare global {
227
248
  slug?: string;
228
249
  /** Lifecycle of every version this entry publishes, unless a ref says otherwise. */
229
250
  status?: DuxtSourceStatusInput;
251
+ /** Lifecycle defaults by ref kind; an explicit ref status wins. */
252
+ statusDefaults?: DuxtSourceStatusDefaultsInput;
230
253
  /**
231
254
  * The repository a source read off disk lives in, for links back to it.
232
255
  * Not `repo`, which is what makes Content download a source.
@@ -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/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.29-765cae3",
4
+ "version": "0.2.1-dev-main.32-677294a",
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>
@@ -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
@@ -368,10 +368,16 @@ export function resolveGeneratedSections(
368
368
  // Claimed per locale, exactly as `resolveSources` claims its own prefixes:
369
369
  // two languages serving one prefix is not a collision, it is the point.
370
370
  const taken = new Map<string, string>(
371
- resolved.map((entry) => [
372
- `${entry.locale ?? ''}|${entry.prefix}`,
373
- `the documentation at "${entry.prefix || '/'}"`
374
- ])
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
+ )
375
381
  );
376
382
 
377
383
  // Counted over the whole list rather than per source, so the identity is
@@ -540,7 +546,10 @@ export function resolveGeneratedSections(
540
546
  }
541
547
  });
542
548
 
543
- assertCollectionIdentities([...resolved, ...generated]);
549
+ assertCollectionIdentities([
550
+ ...resolved.filter((_, index) => expanded[index]!.source.content !== false),
551
+ ...generated
552
+ ]);
544
553
  return generated;
545
554
  }
546
555
 
@@ -706,8 +715,11 @@ export function duxtManifest(
706
715
  options: DuxtSourcesOptions = {},
707
716
  types: DuxtSectionTypes = duxtSectionTypes()
708
717
  ): DuxtResolvedSource[] {
718
+ const resolved = resolveSources(sources, options);
719
+ const expanded = expandSources(sources, options);
720
+
709
721
  return [
710
- ...resolveSources(sources, options),
722
+ ...resolved.filter((_, index) => expanded[index]!.source.content !== false),
711
723
  ...resolveGeneratedSections(sources, options, types)
712
724
  ];
713
725
  }
@@ -1,4 +1,5 @@
1
1
  import { queryCollection } from '@nuxt/content/nitro';
2
+ import { defineEventHandler, setHeader } from 'h3';
2
3
  import { splitLocalePath } from '../../app/utils/locale-path';
3
4
  import { sourcesForRoute } from '../../sources-resolve';
4
5
  import { duxtDefaults, mergeDuxtConfig } from '../../app/utils/duxt-config';
package/sources-git.ts CHANGED
@@ -4,6 +4,8 @@ import {
4
4
  expandSources,
5
5
  isLatestRef,
6
6
  newestTag,
7
+ refIsTag,
8
+ refName,
7
9
  repoUrl
8
10
  } from './sources-resolve';
9
11
 
@@ -102,19 +104,33 @@ export function resolveLatestRefs(sources: DuxtSource[]): DuxtSource[] {
102
104
 
103
105
  return {
104
106
  ...source,
105
- refs: source.refs.map((ref) =>
106
- isLatestRef(ref)
107
- ? {
108
- tag: newest,
109
- // The URL keeps saying `latest`, so a bookmark survives the next
110
- // release; only the ref underneath moves. `label` is what the
111
- // switcher and the prefix are built from.
112
- label:
113
- typeof ref === 'object' ? (ref.label ?? 'latest') : 'latest',
114
- status: typeof ref === 'object' ? ref.status : undefined
115
- }
116
- : ref
117
- )
107
+ refs: source.refs.flatMap((ref) => {
108
+ if (isLatestRef(ref)) {
109
+ return {
110
+ tag: newest,
111
+ // The URL keeps saying `latest`, so a bookmark survives the next
112
+ // release; only the ref underneath moves. `label` is what the
113
+ // switcher and the prefix are built from.
114
+ label: typeof ref === 'object' ? (ref.label ?? 'latest') : 'latest',
115
+ // `latest` stops being distinguishable from an ordinary tag after
116
+ // resolution, so settle its source-level default while that fact
117
+ // still exists. An explicit ref or all-refs source status wins.
118
+ status:
119
+ (typeof ref === 'object' ? ref.status : undefined) ??
120
+ source.status ??
121
+ source.statusDefaults?.latest,
122
+ locales: typeof ref === 'object' ? ref.locales : undefined,
123
+ default: typeof ref === 'object' ? ref.default : undefined
124
+ };
125
+ }
126
+
127
+ // Keep an older release ready in the declaration, without serving the
128
+ // current release twice. Once a newer tag exists this condition stops
129
+ // matching and the retained release becomes its own edition.
130
+ if (refIsTag(ref) && refName(ref) === newest) return [];
131
+
132
+ return ref;
133
+ })
118
134
  };
119
135
  });
120
136
  }
@@ -12,6 +12,14 @@ import type {
12
12
  export interface DuxtSource {
13
13
  /** Folder holding the Markdown, relative to the repository root. */
14
14
  path?: string;
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;
15
23
  /** `owner/name` or a full git URL. Omitted reads the local checkout. */
16
24
  repo?: string;
17
25
  /**
@@ -47,8 +55,8 @@ export interface DuxtSource {
47
55
  *
48
56
  * Named here, everything else follows the ref path exactly: the URL segment,
49
57
  * the switcher entry (scoped to the same artefact), the banner and the
50
- * canonical. `sourceOptions.defaultRef` names which of them is served without
51
- * a prefix, whether it is a ref or one of these.
58
+ * canonical. A ref may name itself as the default; otherwise
59
+ * `sourceOptions.defaultRef` names which is served without a prefix.
52
60
  *
53
61
  * Never beside `refs` — a source with both would have to be served at two
54
62
  * prefixes at once, and the resolver says so rather than picking one.
@@ -72,6 +80,12 @@ export interface DuxtSource {
72
80
  * otherwise. See `DuxtSourceStatus`.
73
81
  */
74
82
  status?: DuxtSourceStatus;
83
+ /**
84
+ * Lifecycle defaults by ref kind. An explicit ref `status` wins; `status`
85
+ * above remains the legacy all-refs fallback. A source opts in because only
86
+ * its maintainer knows whether an older release is really deprecated.
87
+ */
88
+ statusDefaults?: DuxtSourceStatusDefaults;
75
89
  /**
76
90
  * The repository these pages live in, for a source read off disk.
77
91
  *
@@ -126,6 +140,16 @@ export type DuxtSourceStatus =
126
140
  | 'deprecated'
127
141
  | 'eol';
128
142
 
143
+ /** Lifecycle defaults a source may opt into for its different ref kinds. */
144
+ export interface DuxtSourceStatusDefaults {
145
+ /** The moving `latest` shorthand, before it becomes a concrete tag. */
146
+ latest?: DuxtSourceStatus;
147
+ /** A tag explicitly listed beside `latest`. */
148
+ tag?: DuxtSourceStatus;
149
+ /** A named branch, such as `main` or a preview branch. */
150
+ branch?: DuxtSourceStatus;
151
+ }
152
+
129
153
  /** A branch by name, or a tag stated as one. */
130
154
  export type DuxtRef =
131
155
  | string
@@ -135,6 +159,13 @@ export type DuxtRef =
135
159
  interface DuxtRefOptions {
136
160
  /** Shown in the switcher and used in the URL; defaults to the ref name. */
137
161
  label?: string;
162
+ /**
163
+ * Serve this ref without a version prefix, even when another source has its
164
+ * own default. A site with several independently versioned trees needs this:
165
+ * its docs can serve the newest release at `/` while an API stays at its own
166
+ * current edition.
167
+ */
168
+ default?: boolean;
138
169
  /** This one version's lifecycle, overriding the source's. */
139
170
  status?: DuxtSourceStatus;
140
171
  /**
@@ -569,7 +600,10 @@ export function resolveSources(
569
600
  const code = locale ? localeCode(locale) : defaultLocale;
570
601
  const isDefaultLocale = !code || code === defaultLocale;
571
602
  const version = name ? slugify(label ?? name) : undefined;
572
- const isDefault = !name || name === defaultRef;
603
+ const isDefault =
604
+ !name ||
605
+ (ref && typeof ref === 'object' && Boolean(ref.default)) ||
606
+ name === defaultRef;
573
607
 
574
608
  const segments: string[] = [];
575
609
  if (segmented(source)) segments.push(repoSlug(source));
@@ -589,21 +623,23 @@ export function resolveSources(
589
623
 
590
624
  // Claimed per locale: two languages serving one prefix is not a collision,
591
625
  // it is the point.
592
- const claim = `${code ?? ''}|${prefix}`;
593
- const previous = taken.get(claim);
594
- if (previous) {
595
- // The one ambiguity the build-time decision leaves: a docs folder named
596
- // like a repository or a version. Rejected rather than resolved silently.
597
- throw new Error(
598
- `duxt: two sources resolve to the same URL prefix "${prefix || '/'}" ` +
599
- `(${previous} and ${source.repo ?? 'this repository'}${ref ? `@${ref}` : ''}). ` +
600
- 'Give one of them a `slug` or a `label`.'
626
+ if (source.content !== false) {
627
+ const claim = `${code ?? ''}|${prefix}`;
628
+ const previous = taken.get(claim);
629
+ if (previous) {
630
+ // The one ambiguity the build-time decision leaves: a docs folder named
631
+ // like a repository or a version. Rejected rather than resolved silently.
632
+ throw new Error(
633
+ `duxt: two sources resolve to the same URL prefix "${prefix || '/'}" ` +
634
+ `(${previous} and ${source.repo ?? 'this repository'}${ref ? `@${ref}` : ''}). ` +
635
+ 'Give one of them a `slug` or a `label`.'
636
+ );
637
+ }
638
+ taken.set(
639
+ claim,
640
+ `${source.repo ?? 'this repository'}${name ? `@${name}` : ''}`
601
641
  );
602
642
  }
603
- taken.set(
604
- claim,
605
- `${source.repo ?? 'this repository'}${name ? `@${name}` : ''}`
606
- );
607
643
 
608
644
  resolved.push({
609
645
  collection,
@@ -629,6 +665,11 @@ export function resolveSources(
629
665
  status:
630
666
  (ref && typeof ref === 'object' ? ref.status : undefined) ??
631
667
  source.status ??
668
+ (effectiveRef
669
+ ? refIsTag(effectiveRef)
670
+ ? source.statusDefaults?.tag
671
+ : source.statusDefaults?.branch
672
+ : undefined) ??
632
673
  'current',
633
674
  // A local source is a full checkout already; a remote one has to be
634
675
  // unshallowed, which is why it has to be asked for.
@@ -636,7 +677,9 @@ export function resolveSources(
636
677
  });
637
678
  }
638
679
 
639
- assertCollectionIdentities(resolved);
680
+ assertCollectionIdentities(
681
+ resolved.filter((_, index) => expanded[index]!.source.content !== false)
682
+ );
640
683
  return resolved;
641
684
  }
642
685
 
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
  }