svelte-docsmith 0.9.0 → 0.10.0

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.
Files changed (45) hide show
  1. package/bin/svelte-docsmith.mjs +161 -0
  2. package/dist/buildtime/archive.d.ts +31 -0
  3. package/dist/buildtime/archive.js +50 -0
  4. package/dist/buildtime/markdown-source.d.ts +31 -0
  5. package/dist/buildtime/markdown-source.js +90 -0
  6. package/dist/buildtime/vite/collect.d.ts +4 -3
  7. package/dist/buildtime/vite/collect.js +31 -12
  8. package/dist/buildtime/vite/extract.js +9 -35
  9. package/dist/buildtime/vite/frontmatter.js +4 -3
  10. package/dist/buildtime/vite/index.d.ts +9 -0
  11. package/dist/buildtime/vite/index.js +11 -4
  12. package/dist/components/chrome/copy-button.svelte +3 -2
  13. package/dist/components/chrome/search.svelte +16 -4
  14. package/dist/components/chrome/search.svelte.d.ts +4 -1
  15. package/dist/components/chrome/version-banner.svelte +82 -0
  16. package/dist/components/chrome/version-banner.svelte.d.ts +10 -0
  17. package/dist/components/chrome/version-switcher.svelte +63 -0
  18. package/dist/components/chrome/version-switcher.svelte.d.ts +14 -0
  19. package/dist/components/layouts/docs-header.svelte +26 -3
  20. package/dist/components/layouts/docs-header.svelte.d.ts +9 -1
  21. package/dist/components/layouts/docs-mobile-header.svelte +25 -2
  22. package/dist/components/layouts/docs-mobile-header.svelte.d.ts +9 -1
  23. package/dist/components/layouts/docs-shell.svelte +84 -66
  24. package/dist/components/layouts/docs-shell.svelte.d.ts +11 -2
  25. package/dist/components/layouts/error-page.svelte +23 -3
  26. package/dist/components/layouts/error-page.svelte.d.ts +8 -2
  27. package/dist/components/layouts/seo-head.svelte +8 -1
  28. package/dist/components/layouts/seo-head.svelte.d.ts +2 -0
  29. package/dist/core/content.d.ts +9 -0
  30. package/dist/core/docs-page.d.ts +70 -0
  31. package/dist/core/docs-page.js +55 -0
  32. package/dist/core/index.d.ts +2 -0
  33. package/dist/core/index.js +2 -0
  34. package/dist/core/version.d.ts +80 -0
  35. package/dist/core/version.js +85 -0
  36. package/dist/fallbacks/content.d.ts +2 -0
  37. package/dist/fallbacks/content.js +1 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +5 -0
  40. package/dist/search/context.svelte.d.ts +2 -0
  41. package/dist/search/context.svelte.js +2 -0
  42. package/dist/search/create-search.d.ts +8 -2
  43. package/dist/search/create-search.js +11 -3
  44. package/dist/theme.css +21 -0
  45. package/package.json +7 -2
@@ -2,16 +2,14 @@
2
2
  import { page } from '$app/state';
3
3
  import { afterNavigate } from '$app/navigation';
4
4
  import {
5
- navFromContent,
6
- flattenNav,
7
- navTrail,
5
+ resolveDocsPage,
8
6
  type DocsContentItem,
9
7
  type DocsmithConfig,
10
- type SearchDoc
8
+ type SearchDoc,
9
+ type ResolvedVersion
11
10
  } from '../../core/index.js';
12
11
  import { createToc } from '../../toc/index.js';
13
12
  import { createSearchState } from '../../search/context.svelte.js';
14
- import { normalizePath } from '../../utils/normalize-path.js';
15
13
  import Search from '../chrome/search.svelte';
16
14
  import BackgroundPattern from '../chrome/background-pattern.svelte';
17
15
  import ThemeProvider from '../chrome/theme-provider.svelte';
@@ -21,10 +19,11 @@
21
19
  import DocsMobileHeader from './docs-mobile-header.svelte';
22
20
  import DocsSidebar from './docs-sidebar.svelte';
23
21
  import PrevNextNav from './prev-next-nav.svelte';
24
- import Breadcrumbs, { type Crumb } from './breadcrumbs.svelte';
22
+ import Breadcrumbs from './breadcrumbs.svelte';
25
23
  import CopyPageMenu from './copy-page-menu.svelte';
26
24
  import PageFeedback from './page-feedback.svelte';
27
25
  import SeoHead from './seo-head.svelte';
26
+ import VersionBanner from '../chrome/version-banner.svelte';
28
27
  import SquarePen from '@lucide/svelte/icons/square-pen';
29
28
  import TableOfContents from '../chrome/table-of-contents.svelte';
30
29
  import type { Snippet } from 'svelte';
@@ -32,6 +31,7 @@
32
31
  const {
33
32
  config,
34
33
  content = [],
34
+ versions = [],
35
35
  children,
36
36
  logo,
37
37
  actions,
@@ -46,6 +46,14 @@
46
46
  }: {
47
47
  config: DocsmithConfig;
48
48
  content?: DocsContentItem[];
49
+ /**
50
+ * The resolved version manifest from `svelte-docsmith/content` (its
51
+ * `versions` export). Provide it to enable versioned docs — the sidebar,
52
+ * search, prev/next, and breadcrumbs scope to the version being read, and
53
+ * once an archived version exists a header switcher and an archived-version
54
+ * banner appear. Omit for a single tree.
55
+ */
56
+ versions?: ResolvedVersion[];
49
57
  children: Snippet;
50
58
  /**
51
59
  * Override the head tags for this page. Doc pages get their `<title>` and
@@ -57,8 +65,9 @@
57
65
  * Enable the ⌘K search palette by lazily providing the generated index,
58
66
  * e.g. `search={() => import('svelte-docsmith/search').then((m) => m.docs)}`.
59
67
  * Omit to hide search. The index is fetched only when search first opens.
68
+ * Receives the active version id on a versioned site.
60
69
  */
61
- search?: () => Promise<SearchDoc[]>;
70
+ search?: (versionId?: string) => Promise<SearchDoc[]>;
62
71
  /** Custom logo mark for the header and mobile menu. */
63
72
  logo?: Snippet;
64
73
  /** Extra header controls (desktop and mobile), before the theme toggle. */
@@ -98,55 +107,43 @@
98
107
  // `search` loader is static for the shell's lifetime, so reading it once at
99
108
  // init is intentional.
100
109
  // svelte-ignore state_referenced_locally
101
- if (search) createSearchState();
102
-
103
- const nav = $derived(navFromContent(content));
110
+ const searchState = search ? createSearchState() : undefined;
104
111
 
105
- // Match content by a trailing-slash-normalized path, so `/docs/intro/` and
106
- // `/docs/intro` resolve to the same page regardless of the app's trailingSlash.
107
- const pathname = $derived(normalizePath(page.url.pathname));
112
+ // Every rule about the page being read is resolved in one pure module
113
+ // (`core/docs-page.ts`): the active version and the content scoped to it, the
114
+ // sidebar, the entry, prev/next, breadcrumbs, the server-rendered TOC. Reading
115
+ // them off one view keeps the parts from disagreeing about which page or
116
+ // version is active. What stays here is presentation: locale formatting, the
117
+ // "min read" copy, and the live TOC.
118
+ const view = $derived(
119
+ resolveDocsPage({
120
+ content,
121
+ versions,
122
+ pathname: page.url.pathname,
123
+ editUrl: config.editUrl,
124
+ siteTitle: config.title
125
+ })
126
+ );
108
127
 
109
- // The content entry for the current route drives the SEO title/description,
110
- // the "Edit this page" link, and the "Last updated" stamp.
111
- const currentEntry = $derived(content.find((item) => item.path === pathname));
128
+ // Keep the search palette scoped to the version currently being read.
129
+ $effect(() => {
130
+ if (searchState) searchState.version = view.activeVersionId;
131
+ });
112
132
 
113
- const editHref = $derived(
114
- config.editUrl && currentEntry?.sourcePath
115
- ? config.editUrl.replace(/\/$/, '') + '/' + currentEntry.sourcePath
133
+ const lastUpdatedLabel = $derived.by(() =>
134
+ view.lastUpdated
135
+ ? new Intl.DateTimeFormat('en', { year: 'numeric', month: 'short', day: 'numeric' }).format(
136
+ view.lastUpdated
137
+ )
116
138
  : undefined
117
139
  );
118
- const lastUpdatedLabel = $derived.by(() => {
119
- const iso = currentEntry?.lastUpdated;
120
- if (!iso) return undefined;
121
- const d = new Date(iso);
122
- return Number.isNaN(d.getTime())
123
- ? undefined
124
- : new Intl.DateTimeFormat('en', { year: 'numeric', month: 'short', day: 'numeric' }).format(
125
- d
126
- );
127
- });
128
- const showFooterMeta = $derived(Boolean(editHref || lastUpdatedLabel));
140
+ const showFooterMeta = $derived(Boolean(view.editHref || lastUpdatedLabel));
129
141
 
130
142
  const readingTimeLabel = $derived(
131
- readingTime && currentEntry?.readingTime ? `${currentEntry.readingTime} min read` : undefined
143
+ readingTime && view.readingMinutes ? `${view.readingMinutes} min read` : undefined
132
144
  );
133
145
 
134
- // Ordered flat page list (leaves in sidebar reading order) drives prev/next.
135
- const flatNav = $derived(flattenNav(nav));
136
- const pageIndex = $derived(flatNav.findIndex((item) => item.url === pathname));
137
- const prev = $derived(pageIndex > 0 ? flatNav[pageIndex - 1] : undefined);
138
- const next = $derived(
139
- pageIndex >= 0 && pageIndex < flatNav.length - 1 ? flatNav[pageIndex + 1] : undefined
140
- );
141
- const currentTitle = $derived(pageIndex >= 0 ? flatNav[pageIndex].title : config.title);
142
-
143
- // Breadcrumb trail: the current page's sidebar group path, then the page
144
- // itself (so a nested page reads e.g. Guides > Advanced > Middleware).
145
- const breadcrumbs = $derived.by(() => {
146
- const crumbs: Crumb[] = (navTrail(nav, pathname) ?? []).map((title) => ({ title }));
147
- if (pageIndex >= 0) crumbs.push({ title: currentTitle });
148
- return crumbs;
149
- });
146
+ const breadcrumbs = $derived(view.breadcrumbs.map((title) => ({ title })));
150
147
 
151
148
  // In-page TOC, scanned from the rendered content and re-scanned after every
152
149
  // navigation (client-side included) so it never goes stale.
@@ -156,17 +153,17 @@
156
153
  toc.refresh();
157
154
  });
158
155
 
159
- // Server-rendered TOC (headings extracted at build time into the content
160
- // index) so the list is present on first paint. Once the client engine has
161
- // scanned the DOM, its items take over (scroll-spy + edge-case accuracy).
162
- const pageToc = $derived(content.find((item) => item.path === pathname)?.toc ?? []);
163
- const tocItems = $derived(toc.items.length ? toc.items : pageToc);
156
+ // The build-time TOC from the content index is present on first paint; once
157
+ // the client engine has scanned the DOM, its items take over (scroll-spy +
158
+ // edge-case accuracy).
159
+ const tocItems = $derived(toc.items.length ? toc.items : view.toc);
164
160
  </script>
165
161
 
166
162
  <SeoHead
167
163
  {config}
168
- title={seo?.title ?? currentEntry?.title}
169
- description={seo?.description ?? currentEntry?.description}
164
+ title={seo?.title ?? view.entry?.title}
165
+ description={seo?.description ?? view.entry?.description}
166
+ noindex={view.activeVersion?.noindex}
170
167
  />
171
168
 
172
169
  <div class="relative isolate flex min-h-screen flex-col">
@@ -197,7 +194,15 @@
197
194
 
198
195
  <!-- One header system everywhere: DocsHeader on desktop, DocsMobileHeader
199
196
  below lg. The `page` layout just omits the sidebar nav and in-page TOC. -->
200
- <DocsHeader {config} {logo} {actions} />
197
+ <DocsHeader
198
+ {config}
199
+ {logo}
200
+ {actions}
201
+ {versions}
202
+ active={view.activeVersion}
203
+ {content}
204
+ pathname={view.pathname}
205
+ />
201
206
 
202
207
  {#if layout === 'page'}
203
208
  <DocsMobileHeader {config} {logo} {actions} />
@@ -208,16 +213,20 @@
208
213
  {:else}
209
214
  <DocsMobileHeader
210
215
  {config}
211
- {nav}
212
- title={currentTitle}
216
+ nav={view.nav}
217
+ title={view.title}
213
218
  {tocItems}
214
219
  tocActiveId={toc.activeId}
215
220
  {logo}
216
221
  {actions}
222
+ {versions}
223
+ active={view.activeVersion}
224
+ {content}
225
+ pathname={view.pathname}
217
226
  />
218
227
 
219
228
  <div class="mx-auto flex w-full max-w-7xl flex-1 gap-12 px-4 md:px-6 lg:px-8 lg:pt-10">
220
- <DocsSidebar {nav} />
229
+ <DocsSidebar nav={view.nav} />
221
230
 
222
231
  <main
223
232
  bind:this={contentEl}
@@ -225,6 +234,15 @@
225
234
  tabindex="-1"
226
235
  class="min-w-0 flex-1 py-6 lg:py-0"
227
236
  >
237
+ {#if view.isArchived && view.activeVersion && view.currentVersion}
238
+ <VersionBanner
239
+ active={view.activeVersion}
240
+ current={view.currentVersion}
241
+ pathname={view.pathname}
242
+ {content}
243
+ />
244
+ {/if}
245
+
228
246
  <div class="flex items-start justify-between gap-4">
229
247
  <Breadcrumbs items={breadcrumbs} />
230
248
  <div class="flex shrink-0 items-center gap-3">
@@ -233,8 +251,8 @@
233
251
  {readingTimeLabel}
234
252
  </span>
235
253
  {/if}
236
- {#if copyPage && currentEntry}
237
- <CopyPageMenu path={pathname} origin={config.url ?? ''} />
254
+ {#if copyPage && view.entry}
255
+ <CopyPageMenu path={view.pathname} origin={config.url ?? ''} />
238
256
  {/if}
239
257
  </div>
240
258
  </div>
@@ -244,9 +262,9 @@
244
262
  <div
245
263
  class="text-muted-foreground border-border mt-10 flex flex-wrap items-center gap-3 border-t pt-6 text-sm"
246
264
  >
247
- {#if editHref}
265
+ {#if view.editHref}
248
266
  <a
249
- href={editHref}
267
+ href={view.editHref}
250
268
  target="_blank"
251
269
  rel="noopener noreferrer"
252
270
  class="hover:text-foreground inline-flex items-center gap-1.5 transition-colors"
@@ -261,18 +279,18 @@
261
279
  </div>
262
280
  {/if}
263
281
 
264
- {#if feedback && currentEntry}
265
- {#key pathname}
282
+ {#if feedback && view.entry}
283
+ {#key view.pathname}
266
284
  <div class="mt-8">
267
285
  <PageFeedback
268
- path={pathname}
286
+ path={view.pathname}
269
287
  onfeedback={typeof feedback === 'function' ? feedback : undefined}
270
288
  />
271
289
  </div>
272
290
  {/key}
273
291
  {/if}
274
292
 
275
- <PrevNextNav {prev} {next} bordered={!showFooterMeta} />
293
+ <PrevNextNav prev={view.prev} next={view.next} bordered={!showFooterMeta} />
276
294
  </main>
277
295
 
278
296
  <!-- Reserve the TOC column so its content filling in after hydration
@@ -1,8 +1,16 @@
1
- import { type DocsContentItem, type DocsmithConfig, type SearchDoc } from '../../core/index.js';
1
+ import { type DocsContentItem, type DocsmithConfig, type SearchDoc, type ResolvedVersion } from '../../core/index.js';
2
2
  import type { Snippet } from 'svelte';
3
3
  type $$ComponentProps = {
4
4
  config: DocsmithConfig;
5
5
  content?: DocsContentItem[];
6
+ /**
7
+ * The resolved version manifest from `svelte-docsmith/content` (its
8
+ * `versions` export). Provide it to enable versioned docs — the sidebar,
9
+ * search, prev/next, and breadcrumbs scope to the version being read, and
10
+ * once an archived version exists a header switcher and an archived-version
11
+ * banner appear. Omit for a single tree.
12
+ */
13
+ versions?: ResolvedVersion[];
6
14
  children: Snippet;
7
15
  /**
8
16
  * Override the head tags for this page. Doc pages get their `<title>` and
@@ -17,8 +25,9 @@ type $$ComponentProps = {
17
25
  * Enable the ⌘K search palette by lazily providing the generated index,
18
26
  * e.g. `search={() => import('svelte-docsmith/search').then((m) => m.docs)}`.
19
27
  * Omit to hide search. The index is fetched only when search first opens.
28
+ * Receives the active version id on a versioned site.
20
29
  */
21
- search?: () => Promise<SearchDoc[]>;
30
+ search?: (versionId?: string) => Promise<SearchDoc[]>;
22
31
  /** Custom logo mark for the header and mobile menu. */
23
32
  logo?: Snippet;
24
33
  /** Extra header controls (desktop and mobile), before the theme toggle. */
@@ -1,6 +1,11 @@
1
1
  <script lang="ts">
2
2
  import { page } from '$app/state';
3
- import type { DocsContentItem, DocsmithConfig, SearchDoc } from '../../core/index.js';
3
+ import type {
4
+ DocsContentItem,
5
+ DocsmithConfig,
6
+ ResolvedVersion,
7
+ SearchDoc
8
+ } from '../../core/index.js';
4
9
  import DocsShell from './docs-shell.svelte';
5
10
  import ArrowLeft from '@lucide/svelte/icons/arrow-left';
6
11
  import type { Snippet } from 'svelte';
@@ -8,6 +13,7 @@
8
13
  const {
9
14
  config,
10
15
  content = [],
16
+ versions = [],
11
17
  search,
12
18
  status,
13
19
  title,
@@ -20,8 +26,14 @@
20
26
  config: DocsmithConfig;
21
27
  /** Content index, so the error page keeps the same header/footer as the site. */
22
28
  content?: DocsContentItem[];
29
+ /**
30
+ * The resolved version manifest (same as `DocsShell`). Pass it on a
31
+ * versioned site so an error under an archived prefix keeps that version's
32
+ * search scope, switcher, and `noindex`.
33
+ */
34
+ versions?: ResolvedVersion[];
23
35
  /** Enable the ⌘K search palette on the error page (same loader as DocsShell). */
24
- search?: () => Promise<SearchDoc[]>;
36
+ search?: (versionId?: string) => Promise<SearchDoc[]>;
25
37
  /** HTTP status; defaults to the current `page.status`. */
26
38
  status?: number;
27
39
  /** Heading; defaults to a message keyed off the status. */
@@ -49,7 +61,15 @@
49
61
  );
50
62
  </script>
51
63
 
52
- <DocsShell {config} {content} {search} {pattern} layout="page" seo={{ title: resolvedTitle }}>
64
+ <DocsShell
65
+ {config}
66
+ {content}
67
+ {versions}
68
+ {search}
69
+ {pattern}
70
+ layout="page"
71
+ seo={{ title: resolvedTitle }}
72
+ >
53
73
  <section
54
74
  class="mx-auto flex min-h-[60vh] max-w-2xl flex-col items-center justify-center px-4 py-20 text-center md:px-6"
55
75
  >
@@ -1,11 +1,17 @@
1
- import type { DocsContentItem, DocsmithConfig, SearchDoc } from '../../core/index.js';
1
+ import type { DocsContentItem, DocsmithConfig, ResolvedVersion, SearchDoc } from '../../core/index.js';
2
2
  import type { Snippet } from 'svelte';
3
3
  type $$ComponentProps = {
4
4
  config: DocsmithConfig;
5
5
  /** Content index, so the error page keeps the same header/footer as the site. */
6
6
  content?: DocsContentItem[];
7
+ /**
8
+ * The resolved version manifest (same as `DocsShell`). Pass it on a
9
+ * versioned site so an error under an archived prefix keeps that version's
10
+ * search scope, switcher, and `noindex`.
11
+ */
12
+ versions?: ResolvedVersion[];
7
13
  /** Enable the ⌘K search palette on the error page (same loader as DocsShell). */
8
- search?: () => Promise<SearchDoc[]>;
14
+ search?: (versionId?: string) => Promise<SearchDoc[]>;
9
15
  /** HTTP status; defaults to the current `page.status`. */
10
16
  status?: number;
11
17
  /** Heading; defaults to a message keyed off the status. */
@@ -6,13 +6,16 @@
6
6
  const {
7
7
  config,
8
8
  title,
9
- description
9
+ description,
10
+ noindex = false
10
11
  }: {
11
12
  config: DocsmithConfig;
12
13
  /** The page's own title (frontmatter or override); omit for the site root. */
13
14
  title?: string;
14
15
  /** The page's own description; falls back to `config.description`. */
15
16
  description?: string;
17
+ /** Emit `robots: noindex`, for a doc version explicitly marked hidden. */
18
+ noindex?: boolean;
16
19
  } = $props();
17
20
 
18
21
  // "Page · Site", or just the site title when they would duplicate (the home page).
@@ -35,6 +38,10 @@
35
38
 
36
39
  <svelte:head>
37
40
  <title>{fullTitle}</title>
41
+ {#if noindex}
42
+ <!-- Keep this version out of search results; still follow its links. -->
43
+ <meta name="robots" content="noindex, follow" />
44
+ {/if}
38
45
  {#if metaDescription}
39
46
  <meta name="description" content={metaDescription} />
40
47
  {/if}
@@ -5,6 +5,8 @@ type $$ComponentProps = {
5
5
  title?: string;
6
6
  /** The page's own description; falls back to `config.description`. */
7
7
  description?: string;
8
+ /** Emit `robots: noindex`, for a doc version explicitly marked hidden. */
9
+ noindex?: boolean;
8
10
  };
9
11
  declare const SeoHead: import("svelte").Component<$$ComponentProps, {}, "">;
10
12
  type SeoHead = ReturnType<typeof SeoHead>;
@@ -37,6 +37,11 @@ export type DocsContentItem = {
37
37
  title: string;
38
38
  depth: 2 | 3;
39
39
  }[];
40
+ /**
41
+ * The id of the version this page belongs to, when the site declares
42
+ * versions (`docsmith({ versions })`). `undefined` on unversioned sites.
43
+ */
44
+ version?: string;
40
45
  };
41
46
  /**
42
47
  * One page's searchable record, emitted at build time by the `docsmith()` vite
@@ -54,6 +59,8 @@ export type SearchDoc = {
54
59
  headings: string[];
55
60
  /** Plain-text body: prose and headings, code and markup removed. */
56
61
  text: string;
62
+ /** The page's version id when the site declares versions; else `undefined`. */
63
+ version?: string;
57
64
  };
58
65
  /**
59
66
  * One page's record for LLM-facing output, emitted at build time by the
@@ -70,4 +77,6 @@ export type LlmsDoc = {
70
77
  /** The page's markdown body (frontmatter and `<script>`/`<style>` removed,
71
78
  * headings and code kept), with the title prepended as an `h1`. */
72
79
  content: string;
80
+ /** The page's version id when the site declares versions; else `undefined`. */
81
+ version?: string;
73
82
  };
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Docs page resolution: everything `DocsShell` needs to know about the page
3
+ * being read, derived in one place from the content index, the version manifest
4
+ * and the URL.
5
+ *
6
+ * Pure and framework-agnostic, so each rule is a field with a test rather than
7
+ * a derivation whose only test surface is a rendered tree. The view returns
8
+ * data, never formatted strings: locale formatting and copy stay in the
9
+ * component that renders them.
10
+ */
11
+ import type { DocsContentItem } from './content.js';
12
+ import { type NavGroup, type NavItem } from './nav.js';
13
+ import { type ResolvedVersion } from './version.js';
14
+ /** The page's build-time headings, as extracted into the content index. */
15
+ type PageToc = NonNullable<DocsContentItem['toc']>;
16
+ /** Everything `DocsShell` renders about the current page. */
17
+ export type DocsPageView = {
18
+ /** The current path, trailing slash stripped. Match content against this. */
19
+ pathname: string;
20
+ /**
21
+ * The version owning the page, falling back to the current version off the
22
+ * docs tree (e.g. a `page` layout). `undefined` on an unversioned site.
23
+ */
24
+ activeVersion: ResolvedVersion | undefined;
25
+ /** {@link activeVersion}'s id, for scoping search. */
26
+ activeVersionId: string | undefined;
27
+ /** The current version, for the archived-version banner's "latest" link. */
28
+ currentVersion: ResolvedVersion | undefined;
29
+ /** Whether the page belongs to an archived (frozen) version. */
30
+ isArchived: boolean;
31
+ /** The content index scoped to {@link activeVersion}. */
32
+ scopedContent: DocsContentItem[];
33
+ /** Sidebar nav built from {@link scopedContent}. */
34
+ nav: NavGroup[];
35
+ /** The scoped content entry for this route, if the page is a doc page. */
36
+ entry: DocsContentItem | undefined;
37
+ /** "Edit this page" target. Absent without an edit base, or when archived. */
38
+ editHref: string | undefined;
39
+ /** The page's last commit date, unformatted. Absent if missing or unparseable. */
40
+ lastUpdated?: Date;
41
+ /** Estimated reading time in whole minutes, uncopied. */
42
+ readingMinutes?: number;
43
+ /** Previous page in sidebar reading order. */
44
+ prev: NavItem | undefined;
45
+ /** Next page in sidebar reading order. */
46
+ next: NavItem | undefined;
47
+ /** The page's nav title, falling back to the site title off the nav tree. */
48
+ title: string;
49
+ /**
50
+ * Breadcrumb titles: the page's group trail, then the page itself. Titles
51
+ * only, so this layer never depends on a component's `Crumb` type.
52
+ */
53
+ breadcrumbs: string[];
54
+ /** The page's server-rendered TOC, from {@link entry}. */
55
+ toc: PageToc;
56
+ };
57
+ /**
58
+ * Resolve the page being read. `pathname` is taken raw (normalized here, so the
59
+ * caller has one fewer place to remember) and every field is derived from the
60
+ * same normalized path and the same scoped content, so the sidebar, prev/next,
61
+ * breadcrumbs and TOC can never disagree about which page or version is active.
62
+ */
63
+ export declare function resolveDocsPage(input: {
64
+ content: DocsContentItem[];
65
+ versions: ResolvedVersion[];
66
+ pathname: string;
67
+ editUrl?: string;
68
+ siteTitle: string;
69
+ }): DocsPageView;
70
+ export {};
@@ -0,0 +1,55 @@
1
+ import { navFromContent, flattenNav, navTrail } from './nav.js';
2
+ import { activeVersion, currentVersion, scopeContent } from './version.js';
3
+ import { normalizePath } from '../utils/normalize-path.js';
4
+ /**
5
+ * Resolve the page being read. `pathname` is taken raw (normalized here, so the
6
+ * caller has one fewer place to remember) and every field is derived from the
7
+ * same normalized path and the same scoped content, so the sidebar, prev/next,
8
+ * breadcrumbs and TOC can never disagree about which page or version is active.
9
+ */
10
+ export function resolveDocsPage(input) {
11
+ const { content, versions, editUrl, siteTitle } = input;
12
+ const pathname = normalizePath(input.pathname);
13
+ // Off the docs tree there is no owning version, so fall back to the current
14
+ // one: a `page` layout still gets the current sidebar and search scope.
15
+ const active = activeVersion(versions, pathname) ?? (versions.length ? currentVersion(versions) : undefined);
16
+ const current = currentVersion(versions);
17
+ // An archived version is frozen, so don't invite edits to it.
18
+ const isArchived = Boolean(active && !active.current);
19
+ const scopedContent = scopeContent(content, active?.id);
20
+ const nav = navFromContent(scopedContent);
21
+ const entry = scopedContent.find((item) => item.path === pathname);
22
+ const flatNav = flattenNav(nav);
23
+ const pageIndex = flatNav.findIndex((item) => item.url === pathname);
24
+ const title = pageIndex >= 0 ? flatNav[pageIndex].title : siteTitle;
25
+ const breadcrumbs = navTrail(nav, pathname) ?? [];
26
+ if (pageIndex >= 0)
27
+ breadcrumbs.push(title);
28
+ return {
29
+ pathname,
30
+ activeVersion: active,
31
+ activeVersionId: active?.id,
32
+ currentVersion: current,
33
+ isArchived,
34
+ scopedContent,
35
+ nav,
36
+ entry,
37
+ editHref: editUrl && entry?.sourcePath && !isArchived
38
+ ? editUrl.replace(/\/$/, '') + '/' + entry.sourcePath
39
+ : undefined,
40
+ lastUpdated: parseDate(entry?.lastUpdated),
41
+ readingMinutes: entry?.readingTime,
42
+ prev: pageIndex > 0 ? flatNav[pageIndex - 1] : undefined,
43
+ next: pageIndex >= 0 && pageIndex < flatNav.length - 1 ? flatNav[pageIndex + 1] : undefined,
44
+ title,
45
+ breadcrumbs,
46
+ toc: entry?.toc ?? []
47
+ };
48
+ }
49
+ /** An ISO stamp as a Date, or `undefined` when absent or unparseable. */
50
+ function parseDate(iso) {
51
+ if (!iso)
52
+ return undefined;
53
+ const date = new Date(iso);
54
+ return Number.isNaN(date.getTime()) ? undefined : date;
55
+ }
@@ -1,3 +1,5 @@
1
1
  export { defineConfig, type DocsmithConfig, type DocsmithLink, type DocsmithFooterColumn, type DocsmithAnnouncement } from './config.js';
2
2
  export type { DocsContentItem, SearchDoc, LlmsDoc } from './content.js';
3
+ export { resolveVersions, activeVersion, currentVersion, currentOnly, scopeContent, mapPathToVersion, type DocsVersion, type DocsVersions, type ResolvedVersion } from './version.js';
3
4
  export { navFromContent, flattenNav, navTrail, isNavGroup, type NavItem, type NavGroup, type NavNode } from './nav.js';
5
+ export { resolveDocsPage, type DocsPageView } from './docs-page.js';
@@ -3,4 +3,6 @@
3
3
  // several of these (e.g. the shell) import from here; the public surface is
4
4
  // re-exported through `src/lib/index.ts`.
5
5
  export { defineConfig } from './config.js';
6
+ export { resolveVersions, activeVersion, currentVersion, currentOnly, scopeContent, mapPathToVersion } from './version.js';
6
7
  export { navFromContent, flattenNav, navTrail, isNavGroup } from './nav.js';
8
+ export { resolveDocsPage } from './docs-page.js';
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Versioned-docs domain logic. Pure and framework-agnostic: the vite plugin
3
+ * resolves the author's versions against the docs URL base at build time and
4
+ * emits the manifest; `DocsShell` and the switcher read it at runtime.
5
+ *
6
+ * The current version is served unprefixed at the docs root and is the only one
7
+ * anyone edits; superseded releases are frozen copies under their own prefix.
8
+ * See `docs/adr/0001-unprefixed-current-docs.md`. All of it is a no-op when a
9
+ * site declares no versions.
10
+ */
11
+ import type { DocsContentItem } from './content.js';
12
+ /** One documentation version, declared in the `docsmith()` vite plugin. */
13
+ export type DocsVersion = {
14
+ /** Stable id. Also the URL/directory segment for an archived version. */
15
+ id: string;
16
+ /** Label shown in the switcher and banner, e.g. `'v2'`. */
17
+ label: string;
18
+ /** Keep this version out of search engines. Defaults to `false`. */
19
+ noindex?: boolean;
20
+ };
21
+ /**
22
+ * A site's versions. `current` is the docs for the latest release: it lives at
23
+ * the docs root, is served unprefixed, and is the folder you edit. `archived`
24
+ * holds frozen copies of superseded releases, each in `<docs root>/<id>/`, in
25
+ * the order they should appear in the switcher (newest first).
26
+ */
27
+ export type DocsVersions = {
28
+ current: DocsVersion;
29
+ archived?: DocsVersion[];
30
+ };
31
+ /** A {@link DocsVersion} with the URL fields the runtime needs, computed at build time. */
32
+ export type ResolvedVersion = DocsVersion & {
33
+ /** Absolute URL base: the docs root for `current`, `/docs/<id>` for an archive. */
34
+ basePath: string;
35
+ /** The version's first page in sidebar order, for the switcher's fallback. */
36
+ landing: string;
37
+ /** Whether this is the current version (as opposed to an archived one). */
38
+ current: boolean;
39
+ /** Whether search engines should skip this version. */
40
+ noindex: boolean;
41
+ };
42
+ /**
43
+ * Resolve author versions against the docs URL base (e.g. `/docs`) and the
44
+ * collected content: compute each version's `basePath` and its `landing` (first
45
+ * page in sidebar reading order). Emitted as the `versions` manifest in
46
+ * `svelte-docsmith/content`, current first, then archives in declared order.
47
+ *
48
+ * Returns an empty manifest for an unversioned site, which makes every
49
+ * downstream scoping, switcher and banner step a no-op.
50
+ */
51
+ export declare function resolveVersions(versions: DocsVersions | undefined, docsBase: string, content: DocsContentItem[]): ResolvedVersion[];
52
+ /**
53
+ * The resolved version owning `pathname`, by longest matching `basePath`. An
54
+ * archive's base is longer than the current version's, so `/docs/v1/x` picks
55
+ * `v1` while `/docs/x` falls to the current version. `undefined` off the docs
56
+ * tree entirely, or on an unversioned site.
57
+ */
58
+ export declare function activeVersion(versions: ResolvedVersion[], pathname: string): ResolvedVersion | undefined;
59
+ /** The current version: the docs for the latest release. */
60
+ export declare function currentVersion(versions: ResolvedVersion[]): ResolvedVersion | undefined;
61
+ /**
62
+ * Keep only the current version's pages, for `sitemap.xml` and `llms.txt`, so
63
+ * search engines and LLMs index one canonical set. A no-op when the site
64
+ * declares no versions. Works over any record carrying `version`.
65
+ */
66
+ export declare function currentOnly<T extends {
67
+ version?: string;
68
+ }>(items: T[], versions: ResolvedVersion[]): T[];
69
+ /**
70
+ * Content scoped to one version. With no version id (unversioned site) the
71
+ * content passes through unchanged, so every caller stays a no-op off the
72
+ * versioned path.
73
+ */
74
+ export declare function scopeContent(content: DocsContentItem[], versionId: string | undefined): DocsContentItem[];
75
+ /**
76
+ * Map the current page to its equivalent under `to`: swap the version base, and
77
+ * fall back to the target's landing page when that doc doesn't exist there.
78
+ * `targetPaths` is the set of the target version's page URLs.
79
+ */
80
+ export declare function mapPathToVersion(pathname: string, from: ResolvedVersion, to: ResolvedVersion, targetPaths: Iterable<string>): string;