svelte-docsmith 0.7.0 → 0.8.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.
@@ -4,6 +4,34 @@ import { listPageFiles } from './pages.js';
4
4
  import { parseFrontmatter } from './frontmatter.js';
5
5
  import { extractLlmsContent, extractSearchText, extractToc, readingMinutes } from './extract.js';
6
6
  import { lastCommitDate } from './git.js';
7
+ /**
8
+ * Read a page's `section` frontmatter: a string is one level, an array is a
9
+ * nested group path. Non-string array members are dropped; anything else is
10
+ * treated as no section.
11
+ */
12
+ function readSection(value) {
13
+ if (typeof value === 'string')
14
+ return value;
15
+ if (Array.isArray(value)) {
16
+ const segs = value.filter((s) => typeof s === 'string');
17
+ return segs.length ? segs : undefined;
18
+ }
19
+ return undefined;
20
+ }
21
+ /** The most-specific (last) group segment, for the compact search-result pill. */
22
+ function sectionLabel(value) {
23
+ const section = readSection(value);
24
+ if (section === undefined)
25
+ return undefined;
26
+ return Array.isArray(section) ? section[section.length - 1] : section;
27
+ }
28
+ /** The full group path joined, so nested pages group under one llms.txt heading. */
29
+ function sectionKey(value) {
30
+ const section = readSection(value);
31
+ if (section === undefined)
32
+ return undefined;
33
+ return Array.isArray(section) ? section.join(' / ') : section;
34
+ }
7
35
  /**
8
36
  * Walk every nav-worthy page under `contentDir` once: a page is nav-worthy when
9
37
  * its frontmatter has a string `title`. Yields the raw source, parsed
@@ -39,7 +67,7 @@ export function collectDocs(contentDir, routesDir) {
39
67
  title,
40
68
  path: url,
41
69
  description: typeof front.description === 'string' ? front.description : undefined,
42
- section: typeof front.section === 'string' ? front.section : undefined,
70
+ section: readSection(front.section),
43
71
  order: typeof front.order === 'number' ? front.order : undefined,
44
72
  sourcePath: path.relative(process.cwd(), file).split(path.sep).join('/'),
45
73
  lastUpdated: lastCommitDate(file),
@@ -69,7 +97,7 @@ export function collectSearchDocs(contentDir, routesDir) {
69
97
  docs.push({
70
98
  path: url,
71
99
  title,
72
- section: typeof front.section === 'string' ? front.section : undefined,
100
+ section: sectionLabel(front.section),
73
101
  description: typeof front.description === 'string' ? front.description : undefined,
74
102
  headings: extractToc(source).map((entry) => entry.title),
75
103
  text: extractSearchText(source)
@@ -91,7 +119,7 @@ export function collectLlmsDocs(contentDir, routesDir) {
91
119
  docs.push({
92
120
  path: url,
93
121
  title,
94
- section: typeof front.section === 'string' ? front.section : undefined,
122
+ section: sectionKey(front.section),
95
123
  order: typeof front.order === 'number' ? front.order : undefined,
96
124
  description: typeof front.description === 'string' ? front.description : undefined,
97
125
  content: extractLlmsContent(source, title)
@@ -0,0 +1,188 @@
1
+ <script lang="ts">
2
+ import { BROWSER } from 'esm-env';
3
+ import X from '@lucide/svelte/icons/x';
4
+ import ArrowRight from '@lucide/svelte/icons/arrow-right';
5
+ import type { DocsmithAnnouncement } from '../../core/index.js';
6
+
7
+ const { announcement }: { announcement: DocsmithAnnouncement } = $props();
8
+
9
+ const dismissible = $derived(announcement.dismissible ?? true);
10
+ const storageKey = $derived('docsmith-announcement:' + (announcement.id ?? announcement.text));
11
+
12
+ // Rendered visible on the server; the dismissal is read after mount so the
13
+ // first client render matches the server and there's no hydration mismatch.
14
+ // The blocking <head> script below is what actually prevents the flash on a
15
+ // reload — this effect just removes the (already-hidden) element from the DOM.
16
+ let dismissed = $state(false);
17
+ $effect(() => {
18
+ if (!dismissible) return;
19
+ try {
20
+ dismissed = localStorage.getItem(storageKey) === '1';
21
+ } catch {
22
+ // storage unavailable; treat as not dismissed
23
+ }
24
+ });
25
+
26
+ function dismiss() {
27
+ dismissed = true;
28
+ if (BROWSER) {
29
+ try {
30
+ localStorage.setItem(storageKey, '1');
31
+ } catch {
32
+ // storage unavailable; dismissal holds for this session only
33
+ }
34
+ }
35
+ }
36
+
37
+ // A blocking snippet run in <head> before first paint: if this bar was
38
+ // already dismissed, hide it via an injected style so it is never painted
39
+ // and can't shift the layout when hydration removes it. Targets a stable
40
+ // class (not the scoped one) so it also works in dev, where component CSS is
41
+ // injected after paint. localStorage is read here because it isn't available
42
+ // during SSR.
43
+ const preventFlashScript = $derived(
44
+ `(function(){try{if(localStorage.getItem(${JSON.stringify(storageKey)})==="1"){` +
45
+ `var s=document.createElement("style");` +
46
+ `s.textContent=".docsmith-announcement{display:none!important}";` +
47
+ `document.head.appendChild(s)}}catch(e){}})()`
48
+ );
49
+ </script>
50
+
51
+ <svelte:head>
52
+ {#if dismissible}
53
+ <!-- The `<\/script>` escape keeps the Svelte parser from ending the tag early. -->
54
+ <!-- eslint-disable-next-line svelte/no-at-html-tags, no-useless-escape -->
55
+ {@html `<script>${preventFlashScript}<\/script>`}
56
+ {/if}
57
+ </svelte:head>
58
+
59
+ {#if !dismissed}
60
+ <div class="announcement docsmith-announcement" role="region" aria-label="Announcement">
61
+ {#if announcement.href}
62
+ <a
63
+ class="announcement-body announcement-link"
64
+ href={announcement.href}
65
+ target={announcement.external ? '_blank' : undefined}
66
+ rel={announcement.external ? 'noopener noreferrer' : undefined}
67
+ >
68
+ {#if announcement.tag}<span class="announcement-tag">{announcement.tag}</span>{/if}
69
+ <span class="announcement-text">{announcement.text}</span>
70
+ <ArrowRight class="announcement-arrow size-3.5" aria-hidden="true" />
71
+ </a>
72
+ {:else}
73
+ <div class="announcement-body">
74
+ {#if announcement.tag}<span class="announcement-tag">{announcement.tag}</span>{/if}
75
+ <span class="announcement-text">{announcement.text}</span>
76
+ </div>
77
+ {/if}
78
+
79
+ {#if dismissible}
80
+ <button
81
+ type="button"
82
+ class="announcement-dismiss"
83
+ onclick={dismiss}
84
+ aria-label="Dismiss announcement"
85
+ >
86
+ <X class="size-3.5" aria-hidden="true" />
87
+ </button>
88
+ {/if}
89
+ </div>
90
+ {/if}
91
+
92
+ <style>
93
+ /* A thin bar above the header. The warm accent is spent on a solid tag, not
94
+ a full-width wash, so it reads as a deliberate mark; text stays on
95
+ --foreground for AA contrast. */
96
+ .announcement {
97
+ position: relative;
98
+ display: flex;
99
+ align-items: center;
100
+ justify-content: center;
101
+ min-height: 2.5rem;
102
+ padding: 0.4rem 2.75rem;
103
+ background: color-mix(in oklch, var(--primary) 6%, var(--background));
104
+ border-bottom: 1px solid color-mix(in oklch, var(--primary) 20%, var(--border));
105
+ color: var(--foreground);
106
+ font-size: 0.8125rem;
107
+ line-height: 1.4;
108
+ text-align: center;
109
+ }
110
+
111
+ .announcement-body {
112
+ display: inline-flex;
113
+ flex-wrap: wrap;
114
+ align-items: center;
115
+ justify-content: center;
116
+ gap: 0.3rem 0.55rem;
117
+ min-width: 0;
118
+ color: var(--foreground);
119
+ text-decoration: none;
120
+ }
121
+
122
+ /* Solid brand pill — the sanctioned primary-foreground-on-primary pairing, so
123
+ it stays legible in both themes and matches the props-table "required" pill. */
124
+ .announcement-tag {
125
+ flex-shrink: 0;
126
+ background: var(--primary);
127
+ color: var(--primary-foreground);
128
+ font-family: var(--font-mono, ui-monospace, monospace);
129
+ font-size: 0.625rem;
130
+ font-weight: 700;
131
+ letter-spacing: 0.04em;
132
+ text-transform: uppercase;
133
+ padding: 0.15rem 0.5rem;
134
+ border-radius: 9999px;
135
+ line-height: 1.5;
136
+ }
137
+
138
+ .announcement-text {
139
+ color: var(--foreground);
140
+ }
141
+
142
+ .announcement-link :global(.announcement-arrow) {
143
+ flex-shrink: 0;
144
+ color: var(--primary);
145
+ transition: transform 0.2s ease-out;
146
+ }
147
+ .announcement-link:hover :global(.announcement-arrow) {
148
+ transform: translateX(2px);
149
+ }
150
+ @media (prefers-reduced-motion: reduce) {
151
+ .announcement-link :global(.announcement-arrow) {
152
+ transition: none;
153
+ }
154
+ }
155
+
156
+ /* Dismiss sits at the trailing edge, inside the bar's padding. */
157
+ .announcement-dismiss {
158
+ position: absolute;
159
+ top: 50%;
160
+ right: 0.5rem;
161
+ transform: translateY(-50%);
162
+ display: inline-flex;
163
+ align-items: center;
164
+ justify-content: center;
165
+ padding: 0.35rem;
166
+ border: 0;
167
+ background: transparent;
168
+ border-radius: 0.375rem;
169
+ color: var(--muted-foreground);
170
+ cursor: pointer;
171
+ transition:
172
+ color 0.15s ease-out,
173
+ background-color 0.15s ease-out;
174
+ }
175
+ .announcement-dismiss:hover {
176
+ color: var(--foreground);
177
+ background: color-mix(in oklch, var(--foreground) 8%, transparent);
178
+ }
179
+ .announcement-dismiss:focus-visible {
180
+ outline: 2px solid var(--primary);
181
+ outline-offset: 1px;
182
+ }
183
+ @media (prefers-reduced-motion: reduce) {
184
+ .announcement-dismiss {
185
+ transition: none;
186
+ }
187
+ }
188
+ </style>
@@ -0,0 +1,7 @@
1
+ import type { DocsmithAnnouncement } from '../../core/index.js';
2
+ type $$ComponentProps = {
3
+ announcement: DocsmithAnnouncement;
4
+ };
5
+ declare const AnnouncementBar: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type AnnouncementBar = ReturnType<typeof AnnouncementBar>;
7
+ export default AnnouncementBar;
@@ -0,0 +1,10 @@
1
+ /** The current selection for a sync key, or `undefined` if none is stored. */
2
+ export declare function syncedValue(key: string): string | undefined;
3
+ /** Record a selection for a sync key and persist it. Client-only. */
4
+ export declare function setSyncedValue(key: string, value: string): void;
5
+ /**
6
+ * Load a key's persisted selection into the reactive store, once, on the
7
+ * client. Called from an effect (after hydration) so the first client render
8
+ * still matches the server's default and there is no hydration mismatch.
9
+ */
10
+ export declare function hydrateSyncedValue(key: string): void;
@@ -0,0 +1,59 @@
1
+ import { BROWSER } from 'esm-env';
2
+ // Cross-instance tab sync. Every <Tabs syncKey="x"> reads and writes the same
3
+ // entry here, so choosing "pnpm" in one code block selects it in every block
4
+ // with that key, and the choice survives reloads and navigation.
5
+ //
6
+ // The store is a module singleton. It is only ever written on the client (all
7
+ // writers below are BROWSER-guarded), so on the server it stays empty and each
8
+ // request falls back to the default tab — no cross-request state leakage.
9
+ const PREFIX = 'docsmith-tabs:';
10
+ const selections = $state({});
11
+ let listening = false;
12
+ function listenForOtherWindows() {
13
+ if (listening || !BROWSER)
14
+ return;
15
+ listening = true;
16
+ // Mirror choices made in other tabs/windows of the same site.
17
+ window.addEventListener('storage', (event) => {
18
+ if (event.key?.startsWith(PREFIX) && event.newValue !== null) {
19
+ selections[event.key.slice(PREFIX.length)] = event.newValue;
20
+ }
21
+ });
22
+ }
23
+ /** The current selection for a sync key, or `undefined` if none is stored. */
24
+ export function syncedValue(key) {
25
+ return selections[key];
26
+ }
27
+ /** Record a selection for a sync key and persist it. Client-only. */
28
+ export function setSyncedValue(key, value) {
29
+ if (!BROWSER || selections[key] === value)
30
+ return;
31
+ selections[key] = value;
32
+ try {
33
+ localStorage.setItem(PREFIX + key, value);
34
+ }
35
+ catch {
36
+ // Storage can be unavailable (private mode, quota); sync stays in-memory
37
+ // for this session rather than throwing.
38
+ }
39
+ }
40
+ /**
41
+ * Load a key's persisted selection into the reactive store, once, on the
42
+ * client. Called from an effect (after hydration) so the first client render
43
+ * still matches the server's default and there is no hydration mismatch.
44
+ */
45
+ export function hydrateSyncedValue(key) {
46
+ if (!BROWSER)
47
+ return;
48
+ listenForOtherWindows();
49
+ if (key in selections)
50
+ return;
51
+ try {
52
+ const stored = localStorage.getItem(PREFIX + key);
53
+ if (stored !== null)
54
+ selections[key] = stored;
55
+ }
56
+ catch {
57
+ // ignore unreadable storage
58
+ }
59
+ }
@@ -22,29 +22,65 @@
22
22
  import { setContext, type Snippet } from 'svelte';
23
23
  import * as TabsPrimitive from '../shadcn/tabs/index.js';
24
24
  import TabsPhaseScope from './tabs-phase.svelte';
25
+ import { syncedValue, setSyncedValue, hydrateSyncedValue } from './tabs-sync.svelte.js';
25
26
 
26
27
  const {
27
28
  value,
29
+ syncKey,
28
30
  children
29
31
  }: {
30
32
  /** Value/label of the tab selected by default. Defaults to the first tab. */
31
33
  value?: string;
34
+ /**
35
+ * Sync group. Every `<Tabs>` with the same `syncKey` shares its selection
36
+ * (e.g. `"pkg"` for npm/pnpm/yarn blocks), and the choice is remembered
37
+ * across reloads and navigation.
38
+ */
39
+ syncKey?: string;
32
40
  children: Snippet;
33
41
  } = $props();
34
42
 
35
- // The first <TabItem> to register wins the default selection. Captured during
36
- // the collect pass, which renders (below) before <Tabs.Root> reads it — so the
37
- // correct panel is already selected in the server-rendered HTML.
38
- let firstValue = $state<string | undefined>();
43
+ // Each <TabItem> registers its value during the collect pass, which renders
44
+ // (below) before <Tabs.Root> reads them — so the right panel is already
45
+ // selected in the server-rendered HTML. The first registered value is the
46
+ // default; the full list validates a restored sync choice against this block.
47
+ let values = $state<string[]>([]);
39
48
  setContext<TabsContext>(TABS_CTX, {
40
- register: (v) => (firstValue ??= v)
49
+ register: (v) => {
50
+ if (!values.includes(v)) values.push(v);
51
+ }
41
52
  });
53
+ const firstValue = $derived<string | undefined>(values[0]);
54
+
55
+ // Restore a persisted selection after mount (client-only), so it does not
56
+ // diverge from the server's default render until hydration is done. `ready`
57
+ // gates persistence until then, so any value event the tab primitive fires
58
+ // while mounting can't overwrite the reader's stored choice before we read it.
59
+ let ready = $state(false);
60
+ $effect(() => {
61
+ if (syncKey) hydrateSyncedValue(syncKey);
62
+ ready = true;
63
+ });
64
+
65
+ // A restored sync choice (valid for this block) wins, then the explicit
66
+ // `value`, then the first tab. Reactive, so a sibling group's change or a
67
+ // storage restore switches this instance too.
68
+ const selected = $derived.by(() => {
69
+ const fallback = value ?? firstValue ?? '';
70
+ if (!syncKey) return fallback;
71
+ const stored = syncedValue(syncKey);
72
+ return stored && values.includes(stored) ? stored : fallback;
73
+ });
74
+
75
+ function handleValueChange(next: string) {
76
+ if (ready && syncKey && next) setSyncedValue(syncKey, next);
77
+ }
42
78
  </script>
43
79
 
44
80
  <!-- collect: registers each TabItem's value; renders nothing. -->
45
81
  <TabsPhaseScope phase="collect">{@render children()}</TabsPhaseScope>
46
82
 
47
- <TabsPrimitive.Root value={value ?? firstValue ?? ''} class="my-6">
83
+ <TabsPrimitive.Root value={selected} onValueChange={handleValueChange} class="my-6">
48
84
  <TabsPrimitive.List class="not-prose mb-3">
49
85
  <TabsPhaseScope phase="list">{@render children()}</TabsPhaseScope>
50
86
  </TabsPrimitive.List>
@@ -11,6 +11,12 @@ import { type Snippet } from 'svelte';
11
11
  type $$ComponentProps = {
12
12
  /** Value/label of the tab selected by default. Defaults to the first tab. */
13
13
  value?: string;
14
+ /**
15
+ * Sync group. Every `<Tabs>` with the same `syncKey` shares its selection
16
+ * (e.g. `"pkg"` for npm/pnpm/yarn blocks), and the choice is remembered
17
+ * across reloads and navigation.
18
+ */
19
+ syncKey?: string;
14
20
  children: Snippet;
15
21
  };
16
22
  declare const Tabs: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -3,6 +3,8 @@
3
3
  import { afterNavigate } from '$app/navigation';
4
4
  import {
5
5
  navFromContent,
6
+ flattenNav,
7
+ navTrail,
6
8
  type DocsContentItem,
7
9
  type DocsmithConfig,
8
10
  type SearchDoc
@@ -13,6 +15,7 @@
13
15
  import Search from '../chrome/search.svelte';
14
16
  import BackgroundPattern from '../chrome/background-pattern.svelte';
15
17
  import ThemeProvider from '../chrome/theme-provider.svelte';
18
+ import AnnouncementBar from '../chrome/announcement-bar.svelte';
16
19
  import DocsHeader from './docs-header.svelte';
17
20
  import DocsFooter from './docs-footer.svelte';
18
21
  import DocsMobileHeader from './docs-mobile-header.svelte';
@@ -128,8 +131,8 @@
128
131
  readingTime && currentEntry?.readingTime ? `${currentEntry.readingTime} min read` : undefined
129
132
  );
130
133
 
131
- // Ordered flat page list drives the prev/next links.
132
- const flatNav = $derived(nav.flatMap((group) => group.items));
134
+ // Ordered flat page list (leaves in sidebar reading order) drives prev/next.
135
+ const flatNav = $derived(flattenNav(nav));
133
136
  const pageIndex = $derived(flatNav.findIndex((item) => item.url === pathname));
134
137
  const prev = $derived(pageIndex > 0 ? flatNav[pageIndex - 1] : undefined);
135
138
  const next = $derived(
@@ -137,13 +140,10 @@
137
140
  );
138
141
  const currentTitle = $derived(pageIndex >= 0 ? flatNav[pageIndex].title : config.title);
139
142
 
140
- // Breadcrumb trail: the current page's sidebar group, then the page itself.
141
- const currentGroup = $derived(
142
- nav.find((group) => group.items.some((item) => item.url === pathname))
143
- );
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).
144
145
  const breadcrumbs = $derived.by(() => {
145
- const crumbs: Crumb[] = [];
146
- if (currentGroup) crumbs.push({ title: currentGroup.title });
146
+ const crumbs: Crumb[] = (navTrail(nav, pathname) ?? []).map((title) => ({ title }));
147
147
  if (pageIndex >= 0) crumbs.push({ title: currentTitle });
148
148
  return crumbs;
149
149
  });
@@ -189,6 +189,12 @@
189
189
  <BackgroundPattern />
190
190
  {/if}
191
191
 
192
+ <!-- Announcement bar (if configured) sits above the sticky header, so it
193
+ scrolls away while the header stays pinned. -->
194
+ {#if config.announcement}
195
+ <AnnouncementBar announcement={config.announcement} />
196
+ {/if}
197
+
192
198
  <!-- One header system everywhere: DocsHeader on desktop, DocsMobileHeader
193
199
  below lg. The `page` layout just omits the sidebar nav and in-page TOC. -->
194
200
  <DocsHeader {config} {logo} {actions} />
@@ -1,35 +1,69 @@
1
1
  <script lang="ts">
2
2
  import { page } from '$app/state';
3
+ import ChevronRight from '@lucide/svelte/icons/chevron-right';
3
4
  import { cn } from '../../utils/cn.js';
4
5
  import { normalizePath } from '../../utils/normalize-path.js';
5
- import type { NavGroup } from '../../core/index.js';
6
+ import { isNavGroup, type NavGroup, type NavNode } from '../../core/index.js';
6
7
 
7
8
  const { nav, class: className = '' }: { nav: NavGroup[]; class?: string } = $props();
9
+
10
+ const current = $derived(normalizePath(page.url.pathname));
11
+
12
+ // Whether a subtree contains the current page — drives which nested groups
13
+ // start open. Recomputes on navigation, so navigating into a collapsed branch
14
+ // (e.g. via prev/next) opens it, while manual toggles on other branches stick.
15
+ function containsCurrent(node: NavNode): boolean {
16
+ return isNavGroup(node) ? node.items.some(containsCurrent) : node.url === current;
17
+ }
8
18
  </script>
9
19
 
20
+ {#snippet tree(items: NavNode[])}
21
+ <ul class="space-y-1">
22
+ {#each items as node (isNavGroup(node) ? 'g:' + node.title : node.url)}
23
+ {#if isNavGroup(node)}
24
+ <li>
25
+ <details class="group/nav" open={containsCurrent(node)}>
26
+ <summary
27
+ class="text-muted-foreground hover:text-foreground flex cursor-pointer list-none items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors select-none [&::-webkit-details-marker]:hidden"
28
+ >
29
+ <span class="truncate">{node.title}</span>
30
+ <ChevronRight
31
+ class="ml-auto size-3.5 shrink-0 opacity-70 transition-transform duration-200 ease-out group-open/nav:rotate-90 motion-reduce:transition-none"
32
+ aria-hidden="true"
33
+ />
34
+ </summary>
35
+ <div class="border-border/60 mt-1 ml-2 border-l pl-3">
36
+ {@render tree(node.items)}
37
+ </div>
38
+ </details>
39
+ </li>
40
+ {:else}
41
+ <li>
42
+ <a
43
+ href={node.url}
44
+ aria-current={current === node.url ? 'page' : undefined}
45
+ class={cn(
46
+ 'hover:text-primary hover:bg-primary/20 block rounded-md px-2 py-1.5 text-sm transition-colors',
47
+ current === node.url
48
+ ? 'text-primary bg-primary/20 font-medium'
49
+ : 'text-muted-foreground'
50
+ )}
51
+ >
52
+ {node.title}
53
+ </a>
54
+ </li>
55
+ {/if}
56
+ {/each}
57
+ </ul>
58
+ {/snippet}
59
+
10
60
  <aside class={cn('sticky top-24 hidden h-screen w-56 shrink-0 bg-transparent lg:block', className)}>
11
61
  <div class="max-h-[calc(100vh-10rem)] overflow-y-auto px-4">
12
62
  <nav aria-label="Documentation" class="space-y-6">
13
63
  {#each nav as group (group.title)}
14
64
  <div class="space-y-3">
15
65
  <h4 class="text-foreground px-2 text-sm font-semibold">{group.title}</h4>
16
- <ul class="space-y-1">
17
- {#each group.items as item (item.url)}
18
- <li>
19
- <a
20
- href={item.url}
21
- class={cn(
22
- 'hover:text-primary hover:bg-primary/20 block rounded-md px-2 py-1.5 text-sm transition-colors',
23
- normalizePath(page.url.pathname) === item.url
24
- ? 'text-primary bg-primary/20 font-medium'
25
- : 'text-muted-foreground'
26
- )}
27
- >
28
- {item.title}
29
- </a>
30
- </li>
31
- {/each}
32
- </ul>
66
+ {@render tree(group.items)}
33
67
  </div>
34
68
  {/each}
35
69
  </nav>
@@ -1,4 +1,4 @@
1
- import type { NavGroup } from '../../core/index.js';
1
+ import { type NavGroup } from '../../core/index.js';
2
2
  type $$ComponentProps = {
3
3
  nav: NavGroup[];
4
4
  class?: string;
@@ -14,6 +14,25 @@ export type DocsmithFooterColumn = {
14
14
  title: string;
15
15
  links: DocsmithLink[];
16
16
  };
17
+ /** A thin announcement bar shown at the top of every page. */
18
+ export type DocsmithAnnouncement = {
19
+ /** The message text. */
20
+ text: string;
21
+ /** Optional leading pill label, e.g. `'New'`, `'Beta'`, or a version. */
22
+ tag?: string;
23
+ /** Optional link the bar points to. */
24
+ href?: string;
25
+ /** Open the link in a new tab with `rel="noopener"`. */
26
+ external?: boolean;
27
+ /**
28
+ * Dismissal key. A dismissed bar stays dismissed until this value changes, so
29
+ * bump it (or edit `text`) when you post a new announcement. Defaults to the
30
+ * text itself.
31
+ */
32
+ id?: string;
33
+ /** Let readers dismiss the bar. Defaults to `true`. */
34
+ dismissible?: boolean;
35
+ };
17
36
  export type DocsmithConfig = {
18
37
  /** Site title, shown in the header/sidebar and in the `<title>` suffix. */
19
38
  title: string;
@@ -42,6 +61,8 @@ export type DocsmithConfig = {
42
61
  version?: string;
43
62
  /** Optional logo image src; falls back to the built-in book mark. */
44
63
  logo?: string;
64
+ /** Optional announcement bar shown at the top of every page. */
65
+ announcement?: DocsmithAnnouncement;
45
66
  /** Top-level header navigation links. */
46
67
  nav?: DocsmithLink[];
47
68
  /** Footer content, driven by data. */
@@ -23,6 +23,25 @@ export function defineConfig(config) {
23
23
  throw new Error(`[svelte-docsmith] config.${key} must be a string when set.`);
24
24
  }
25
25
  }
26
+ if (config.announcement !== undefined) {
27
+ const a = config.announcement;
28
+ if (typeof a !== 'object' || a === null) {
29
+ throw new Error('[svelte-docsmith] config.announcement must be an object ({ text, href?, id?, external?, dismissible? }).');
30
+ }
31
+ if (typeof a.text !== 'string' || a.text.trim() === '') {
32
+ throw new Error('[svelte-docsmith] config.announcement.text is required and must be a string.');
33
+ }
34
+ for (const key of ['tag', 'href', 'id']) {
35
+ if (a[key] !== undefined && typeof a[key] !== 'string') {
36
+ throw new Error(`[svelte-docsmith] config.announcement.${key} must be a string when set.`);
37
+ }
38
+ }
39
+ for (const key of ['external', 'dismissible']) {
40
+ if (a[key] !== undefined && typeof a[key] !== 'boolean') {
41
+ throw new Error(`[svelte-docsmith] config.announcement.${key} must be a boolean when set.`);
42
+ }
43
+ }
44
+ }
26
45
  if (config.nav !== undefined) {
27
46
  if (!Array.isArray(config.nav)) {
28
47
  throw new Error('[svelte-docsmith] config.nav must be an array of { label, href } links.');
@@ -13,7 +13,12 @@
13
13
  export type DocsContentItem = {
14
14
  title: string;
15
15
  path: string;
16
- section?: string;
16
+ /**
17
+ * Sidebar group. A string places the page in a single top-level group; an
18
+ * array names a nested group path (e.g. `['Guides', 'Advanced']`), building
19
+ * collapsible subsections. Omitted pages fall under "Docs".
20
+ */
21
+ section?: string | string[];
17
22
  order?: number;
18
23
  description?: string;
19
24
  /** Source file path relative to the app cwd, for the "Edit this page" link. */
@@ -1,3 +1,3 @@
1
- export { defineConfig, type DocsmithConfig, type DocsmithLink, type DocsmithFooterColumn } from './config.js';
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 { navFromContent, type NavItem, type NavGroup } from './nav.js';
3
+ export { navFromContent, flattenNav, navTrail, isNavGroup, type NavItem, type NavGroup, type NavNode } from './nav.js';
@@ -3,4 +3,4 @@
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 { navFromContent } from './nav.js';
6
+ export { navFromContent, flattenNav, navTrail, isNavGroup } from './nav.js';
@@ -1,17 +1,34 @@
1
1
  import type { DocsContentItem } from './content.js';
2
- /** A single sidebar link. */
2
+ /** A single sidebar link (a leaf). */
3
3
  export type NavItem = {
4
4
  title: string;
5
5
  url: string;
6
6
  };
7
- /** A titled group of sidebar links. */
7
+ /** A titled group of sidebar entries, which may themselves be nested groups. */
8
8
  export type NavGroup = {
9
9
  title: string;
10
- items: NavItem[];
10
+ items: NavNode[];
11
11
  };
12
+ /** A sidebar entry: either a link or a nested group. */
13
+ export type NavNode = NavItem | NavGroup;
14
+ /** Narrow a {@link NavNode} to a {@link NavGroup} (an entry with children). */
15
+ export declare function isNavGroup(node: NavNode): node is NavGroup;
12
16
  /**
13
- * Derive sidebar nav from a content collection: group by `section`, order by
14
- * `order` within a group, and order groups by the smallest `order` they
15
- * contain. Entries without a `section` fall under "Docs".
17
+ * Derive sidebar nav from a content collection. Each page's `section` (a string
18
+ * or a nested path) places it in the tree; within every level, entries order by
19
+ * `order` (then title), and a group inherits the smallest `order` of its
20
+ * descendants so groups sort by their earliest child. Top-level entries are
21
+ * always groups; pages without a section fall under "Docs".
16
22
  */
17
23
  export declare function navFromContent(content: DocsContentItem[]): NavGroup[];
24
+ /**
25
+ * Flatten the nav tree to its leaf links in sidebar reading order (depth-first),
26
+ * for the prev/next pager.
27
+ */
28
+ export declare function flattenNav(nodes: NavNode[]): NavItem[];
29
+ /**
30
+ * The trail of group titles leading to the page at `url` (top group first,
31
+ * excluding the page itself), or `undefined` if the page is not in the tree.
32
+ * Drives the breadcrumb trail.
33
+ */
34
+ export declare function navTrail(nodes: NavNode[], url: string): string[] | undefined;
package/dist/core/nav.js CHANGED
@@ -1,22 +1,101 @@
1
+ /** Narrow a {@link NavNode} to a {@link NavGroup} (an entry with children). */
2
+ export function isNavGroup(node) {
3
+ return 'items' in node;
4
+ }
5
+ /**
6
+ * Normalize a page's `section` to a group path. A string is a single level; an
7
+ * array is a nested path; blank or missing sections fall under "Docs". Empty
8
+ * strings inside an array are dropped so a stray `['Guides', '']` stays sane.
9
+ */
10
+ function sectionPath(section) {
11
+ const segs = Array.isArray(section) ? section : section ? [section] : [];
12
+ const clean = segs.filter((s) => typeof s === 'string' && s.trim() !== '');
13
+ return clean.length ? clean : ['Docs'];
14
+ }
15
+ function makeBuilder(title) {
16
+ return { title, order: Infinity, groups: new Map(), leaves: [] };
17
+ }
18
+ /**
19
+ * Emit a group's children (nested groups and leaf links) as one list, sorted by
20
+ * `order` and then title. The title tiebreak keeps the output deterministic when
21
+ * pages share an order (e.g. all default to 0), instead of depending on the
22
+ * filesystem scan order.
23
+ */
24
+ function emitItems(node) {
25
+ const entries = [];
26
+ for (const leaf of node.leaves) {
27
+ entries.push({
28
+ order: leaf.order,
29
+ title: leaf.title,
30
+ node: { title: leaf.title, url: leaf.url }
31
+ });
32
+ }
33
+ for (const group of node.groups.values()) {
34
+ entries.push({
35
+ order: group.order,
36
+ title: group.title,
37
+ node: { title: group.title, items: emitItems(group) }
38
+ });
39
+ }
40
+ entries.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title));
41
+ return entries.map((e) => e.node);
42
+ }
1
43
  /**
2
- * Derive sidebar nav from a content collection: group by `section`, order by
3
- * `order` within a group, and order groups by the smallest `order` they
4
- * contain. Entries without a `section` fall under "Docs".
44
+ * Derive sidebar nav from a content collection. Each page's `section` (a string
45
+ * or a nested path) places it in the tree; within every level, entries order by
46
+ * `order` (then title), and a group inherits the smallest `order` of its
47
+ * descendants so groups sort by their earliest child. Top-level entries are
48
+ * always groups; pages without a section fall under "Docs".
5
49
  */
6
50
  export function navFromContent(content) {
7
- const groups = new Map();
51
+ const root = makeBuilder('');
8
52
  for (const item of content) {
9
- const section = item.section ?? 'Docs';
10
53
  const order = item.order ?? 0;
11
- const group = groups.get(section) ?? { minOrder: Infinity, items: [] };
12
- group.items.push({ title: item.title, url: item.path, order });
13
- group.minOrder = Math.min(group.minOrder, order);
14
- groups.set(section, group);
54
+ let node = root;
55
+ for (const seg of sectionPath(item.section)) {
56
+ let child = node.groups.get(seg);
57
+ if (!child) {
58
+ child = makeBuilder(seg);
59
+ node.groups.set(seg, child);
60
+ }
61
+ child.order = Math.min(child.order, order);
62
+ node = child;
63
+ }
64
+ node.leaves.push({ title: item.title, url: item.path, order });
65
+ }
66
+ // The root only ever holds groups (every page has at least one path segment),
67
+ // so its emitted items are all NavGroups.
68
+ return emitItems(root);
69
+ }
70
+ /**
71
+ * Flatten the nav tree to its leaf links in sidebar reading order (depth-first),
72
+ * for the prev/next pager.
73
+ */
74
+ export function flattenNav(nodes) {
75
+ const out = [];
76
+ for (const node of nodes) {
77
+ if (isNavGroup(node))
78
+ out.push(...flattenNav(node.items));
79
+ else
80
+ out.push(node);
81
+ }
82
+ return out;
83
+ }
84
+ /**
85
+ * The trail of group titles leading to the page at `url` (top group first,
86
+ * excluding the page itself), or `undefined` if the page is not in the tree.
87
+ * Drives the breadcrumb trail.
88
+ */
89
+ export function navTrail(nodes, url) {
90
+ for (const node of nodes) {
91
+ if (isNavGroup(node)) {
92
+ const inner = navTrail(node.items, url);
93
+ if (inner)
94
+ return [node.title, ...inner];
95
+ }
96
+ else if (node.url === url) {
97
+ return [];
98
+ }
15
99
  }
16
- return [...groups.entries()]
17
- .sort((a, b) => a[1].minOrder - b[1].minOrder)
18
- .map(([title, group]) => ({
19
- title,
20
- items: group.items.sort((a, b) => a.order - b.order).map(({ title, url }) => ({ title, url }))
21
- }));
100
+ return undefined;
22
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-docsmith",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "A framework for building beautiful documentation sites with Svelte.",
5
5
  "author": {
6
6
  "name": "George Daskalakis",