specra 0.2.67 → 0.2.68

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,8 @@
4
4
  import { ChevronRight, ChevronDown, Lock } from 'lucide-svelte';
5
5
  import type { SpecraConfig } from '../../config.types.js';
6
6
  import Icon from './Icon.svelte';
7
+ import SidebarBadge from './SidebarBadge.svelte';
8
+ import { resolveBadges, type BadgeInput } from '../../badges.js';
7
9
  import { sortSidebarItems, sortSidebarGroups } from '../../sidebar-utils.js';
8
10
  import { renderInlineCode } from '../../inline.js';
9
11
 
@@ -21,11 +23,13 @@
21
23
  categoryCollapsed?: boolean;
22
24
  categoryIcon?: string;
23
25
  categoryTabGroup?: string;
26
+ categoryBadge?: BadgeInput;
24
27
  meta?: {
25
28
  icon?: string;
26
29
  tab_group?: string;
27
30
  sidebar_position?: number;
28
31
  order?: number;
32
+ badge?: BadgeInput;
29
33
  [key: string]: any;
30
34
  };
31
35
  }
@@ -34,6 +38,7 @@
34
38
  label: string;
35
39
  path: string;
36
40
  icon?: string;
41
+ badge?: BadgeInput;
37
42
  items: DocItem[];
38
43
  position: number;
39
44
  collapsible: boolean;
@@ -150,6 +155,7 @@
150
155
  if (isIndexFile) {
151
156
  rootGroups[groupName].position = doc.sidebar_position ?? 999;
152
157
  rootGroups[groupName].icon = doc.categoryIcon;
158
+ rootGroups[groupName].badge = doc.categoryBadge;
153
159
  }
154
160
  rootGroups[groupName].items.push(doc);
155
161
  return;
@@ -168,14 +174,19 @@
168
174
  .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
169
175
  .join(' ');
170
176
 
177
+ const isOwnCategory = i === folderParts.length - 1;
178
+
171
179
  if (!currentLevel[folder]) {
172
180
  currentLevel[folder] = {
173
181
  label:
174
- doc.categoryLabel && i === folderParts.length - 1
182
+ doc.categoryLabel && isOwnCategory
175
183
  ? doc.categoryLabel
176
184
  : folderLabel,
177
185
  path: currentPath,
178
186
  icon: doc.categoryIcon,
187
+ // A doc's `_category_.json` describes its *own* folder, so the
188
+ // badge must not leak onto the ancestor folders we walk through.
189
+ badge: isOwnCategory ? doc.categoryBadge : undefined,
179
190
  items: [],
180
191
  position: doc.categoryPosition ?? 999,
181
192
  collapsible: doc.categoryCollapsible ?? true,
@@ -184,7 +195,10 @@
184
195
  };
185
196
  }
186
197
 
187
- if (i === folderParts.length - 1) {
198
+ if (isOwnCategory) {
199
+ if (doc.categoryBadge) {
200
+ currentLevel[folder].badge = doc.categoryBadge;
201
+ }
188
202
  if (isIndexFile) {
189
203
  currentLevel[folder].position =
190
204
  doc.categoryPosition ?? doc.sidebar_position ?? 999;
@@ -287,6 +301,7 @@
287
301
  {@const marginLeft = depth > 0 ? 'ml-4' : ''}
288
302
  {@const groupHref = getGroupHref(group)}
289
303
  {@const mergedItems = getMergedItems(group)}
304
+ {@const groupBadges = resolveBadges(group.badge)}
290
305
 
291
306
  <div class="space-y-1 {marginLeft}">
292
307
  <div class="flex items-center group">
@@ -296,14 +311,21 @@
296
311
  e.preventDefault();
297
312
  toggleSection(groupKey);
298
313
  }}
299
- class="flex items-center gap-2 flex-1 px-3 py-2 text-sm font-semibold rounded-l-xl transition-all {isGroupActive
314
+ class="flex items-center gap-2 flex-1 min-w-0 px-3 py-2 text-sm font-semibold rounded-l-xl transition-all {isGroupActive
300
315
  ? 'bg-primary/10 text-primary'
301
316
  : 'text-foreground hover:bg-accent/50'}"
302
317
  >
303
318
  {#if group.icon}
304
319
  <Icon icon={group.icon} size={16} className="shrink-0" />
305
320
  {/if}
306
- {@html renderInlineCode(group.label)}
321
+ <span class="truncate">{@html renderInlineCode(group.label)}</span>
322
+ {#if groupBadges.length > 0}
323
+ <span class="ml-auto flex items-center gap-1 shrink-0">
324
+ {#each groupBadges as badge (badge.text)}
325
+ <SidebarBadge {badge} />
326
+ {/each}
327
+ </span>
328
+ {/if}
307
329
  </a>
308
330
 
309
331
  {#if hasContent && group.collapsible && config.navigation?.collapsibleSidebar}
@@ -333,19 +355,27 @@
333
355
  {:else}
334
356
  {@const href = `${docsBase}/${item.doc.slug}`}
335
357
  {@const isActive = pathname === href}
358
+ {@const badges = resolveBadges(item.doc.meta?.badge)}
336
359
  <a
337
360
  {href}
338
361
  onclick={onLinkClick}
339
- class="flex items-center gap-2 px-3 py-2 text-sm rounded-xl transition-all {isActive
362
+ class="flex items-center gap-2 min-w-0 px-3 py-2 text-sm rounded-xl transition-all {isActive
340
363
  ? 'bg-primary/10 text-primary font-medium'
341
364
  : 'text-foreground hover:text-foreground hover:bg-accent/50'}"
342
365
  >
343
366
  {#if item.doc.meta?.icon}
344
367
  <Icon icon={item.doc.meta.icon} size={16} className="shrink-0" />
345
368
  {/if}
346
- {@html renderInlineCode(item.doc.title)}
347
- {#if item.doc.meta?.isProtected}
348
- <Lock size={14} class="shrink-0 text-muted-foreground ml-auto" />
369
+ <span class="truncate">{@html renderInlineCode(item.doc.title)}</span>
370
+ {#if badges.length > 0 || item.doc.meta?.isProtected}
371
+ <span class="ml-auto flex items-center gap-1 shrink-0">
372
+ {#each badges as badge (badge.text)}
373
+ <SidebarBadge {badge} />
374
+ {/each}
375
+ {#if item.doc.meta?.isProtected}
376
+ <Lock size={14} class="text-muted-foreground" />
377
+ {/if}
378
+ </span>
349
379
  {/if}
350
380
  </a>
351
381
  {/if}
@@ -360,19 +390,27 @@
360
390
  {#each sortedStandalone as doc (doc.slug)}
361
391
  {@const href = `${docsBase}/${doc.slug}`}
362
392
  {@const isActive = pathname === href}
393
+ {@const badges = resolveBadges(doc.meta?.badge)}
363
394
  <a
364
395
  {href}
365
396
  onclick={onLinkClick}
366
- class="flex items-center gap-2 px-3 py-2 text-sm rounded-xl transition-all {isActive
397
+ class="flex items-center gap-2 min-w-0 px-3 py-2 text-sm rounded-xl transition-all {isActive
367
398
  ? 'bg-primary/10 text-primary font-medium'
368
399
  : 'text-foreground hover:text-foreground hover:bg-accent/50'}"
369
400
  >
370
401
  {#if doc.meta?.icon}
371
402
  <Icon icon={doc.meta.icon} size={16} className="shrink-0" />
372
403
  {/if}
373
- {@html renderInlineCode(doc.title)}
374
- {#if doc.meta?.isProtected}
375
- <Lock size={14} class="shrink-0 text-muted-foreground ml-auto" />
404
+ <span class="truncate">{@html renderInlineCode(doc.title)}</span>
405
+ {#if badges.length > 0 || doc.meta?.isProtected}
406
+ <span class="ml-auto flex items-center gap-1 shrink-0">
407
+ {#each badges as badge (badge.text)}
408
+ <SidebarBadge {badge} />
409
+ {/each}
410
+ {#if doc.meta?.isProtected}
411
+ <Lock size={14} class="text-muted-foreground" />
412
+ {/if}
413
+ </span>
376
414
  {/if}
377
415
  </a>
378
416
  {/each}
@@ -1,4 +1,5 @@
1
1
  import type { SpecraConfig } from '../../config.types.js';
2
+ import { type BadgeInput } from '../../badges.js';
2
3
  interface DocItem {
3
4
  title: string;
4
5
  slug: string;
@@ -13,11 +14,13 @@ interface DocItem {
13
14
  categoryCollapsed?: boolean;
14
15
  categoryIcon?: string;
15
16
  categoryTabGroup?: string;
17
+ categoryBadge?: BadgeInput;
16
18
  meta?: {
17
19
  icon?: string;
18
20
  tab_group?: string;
19
21
  sidebar_position?: number;
20
22
  order?: number;
23
+ badge?: BadgeInput;
21
24
  [key: string]: any;
22
25
  };
23
26
  }
@@ -0,0 +1,75 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { getChangelogContext } from '../../changelog-context.js';
4
+
5
+ interface Props {
6
+ /** Date or release name. Anchors the entry and names it in the ToC. */
7
+ label?: string;
8
+ /** Slug of `label`, injected server-side by rehype-changelog-ids. */
9
+ id?: string;
10
+ /** Secondary line, typically a version. */
11
+ description?: string;
12
+ tags?: string[];
13
+ /** Overrides the RSS entry. Consumed server-side; unused when rendering. */
14
+ rss?: { title?: string; description?: string };
15
+ children?: Snippet;
16
+ }
17
+
18
+ let { label, id, description, tags = [], children }: Props = $props();
19
+
20
+ // Absent outside a <Changelog>, in which case nothing filters this update.
21
+ const changelog = getChangelogContext();
22
+
23
+ // Reads the parent's selected-tag state, so toggling a filter re-renders.
24
+ let visible = $derived(changelog ? changelog.matches(tags) : true);
25
+ </script>
26
+
27
+ {#if visible}
28
+ <div
29
+ {id}
30
+ class="specra-update grid gap-x-8 gap-y-3 pb-12 scroll-mt-24 last:pb-0 md:grid-cols-[10rem_minmax(0,1fr)]"
31
+ >
32
+ <div class="md:sticky md:top-24 md:self-start md:text-right">
33
+ {#if label}
34
+ {#if id}
35
+ <a
36
+ href="#{id}"
37
+ class="text-sm font-semibold text-foreground transition-colors hover:text-primary"
38
+ >
39
+ {label}
40
+ </a>
41
+ {:else}
42
+ <span class="text-sm font-semibold text-foreground">{label}</span>
43
+ {/if}
44
+ {/if}
45
+
46
+ {#if description}
47
+ <div class="mt-1 text-xs text-muted-foreground">{description}</div>
48
+ {/if}
49
+
50
+ {#if tags.length > 0}
51
+ <div class="mt-2 flex flex-wrap gap-1 md:justify-end">
52
+ {#each tags as tag (tag)}
53
+ <span
54
+ class="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium leading-none text-muted-foreground"
55
+ >
56
+ {tag}
57
+ </span>
58
+ {/each}
59
+ </div>
60
+ {/if}
61
+ </div>
62
+
63
+ <div class="relative border-l border-border pl-8">
64
+ <span
65
+ aria-hidden="true"
66
+ class="absolute -left-[4.5px] top-2 h-2 w-2 rounded-full bg-border ring-4 ring-background"
67
+ ></span>
68
+ <div class="prose prose-sm dark:prose-invert max-w-none [&>*:last-child]:mb-0">
69
+ {#if children}
70
+ {@render children()}
71
+ {/if}
72
+ </div>
73
+ </div>
74
+ </div>
75
+ {/if}
@@ -0,0 +1,19 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ /** Date or release name. Anchors the entry and names it in the ToC. */
4
+ label?: string;
5
+ /** Slug of `label`, injected server-side by rehype-changelog-ids. */
6
+ id?: string;
7
+ /** Secondary line, typically a version. */
8
+ description?: string;
9
+ tags?: string[];
10
+ /** Overrides the RSS entry. Consumed server-side; unused when rendering. */
11
+ rss?: {
12
+ title?: string;
13
+ description?: string;
14
+ };
15
+ children?: Snippet;
16
+ }
17
+ declare const Update: import("svelte").Component<Props, {}, "">;
18
+ type Update = ReturnType<typeof Update>;
19
+ export default Update;
@@ -37,7 +37,10 @@ export { default as MobileSidebarWrapper } from './MobileSidebarWrapper.svelte';
37
37
  export { default as NotFoundContent } from './NotFoundContent.svelte';
38
38
  export { default as SearchHighlight } from './SearchHighlight.svelte';
39
39
  export { default as SearchModal } from './SearchModal.svelte';
40
+ export { default as Changelog } from './Changelog.svelte';
41
+ export { default as Update } from './Update.svelte';
40
42
  export { default as Sidebar } from './Sidebar.svelte';
43
+ export { default as SidebarBadge } from './SidebarBadge.svelte';
41
44
  export { default as SidebarMenuItems } from './SidebarMenuItems.svelte';
42
45
  export { default as SidebarSkeleton } from './SidebarSkeleton.svelte';
43
46
  export { default as SiteBanner } from './SiteBanner.svelte';
@@ -38,7 +38,10 @@ export { default as MobileSidebarWrapper } from './MobileSidebarWrapper.svelte';
38
38
  export { default as NotFoundContent } from './NotFoundContent.svelte';
39
39
  export { default as SearchHighlight } from './SearchHighlight.svelte';
40
40
  export { default as SearchModal } from './SearchModal.svelte';
41
+ export { default as Changelog } from './Changelog.svelte';
42
+ export { default as Update } from './Update.svelte';
41
43
  export { default as Sidebar } from './Sidebar.svelte';
44
+ export { default as SidebarBadge } from './SidebarBadge.svelte';
42
45
  export { default as SidebarMenuItems } from './SidebarMenuItems.svelte';
43
46
  export { default as SidebarSkeleton } from './SidebarSkeleton.svelte';
44
47
  export { default as SiteBanner } from './SiteBanner.svelte';
package/dist/index.d.ts CHANGED
@@ -14,6 +14,9 @@ export * from './utils.js';
14
14
  export * from './links.js';
15
15
  export * from './inline.js';
16
16
  export * from './sidebar-utils.js';
17
+ export * from './badges.js';
18
+ export * from './changelog.js';
19
+ export * from './changelog-context.js';
17
20
  export * from './category.js';
18
21
  export * from './redirects.js';
19
22
  export * from './dev-utils.js';
package/dist/index.js CHANGED
@@ -18,6 +18,9 @@ export * from './utils.js';
18
18
  export * from './links.js';
19
19
  export * from './inline.js';
20
20
  export * from './sidebar-utils.js';
21
+ export * from './badges.js';
22
+ export * from './changelog.js';
23
+ export * from './changelog-context.js';
21
24
  export * from './category.js';
22
25
  export * from './redirects.js';
23
26
  export * from './dev-utils.js';
@@ -14,8 +14,8 @@
14
14
  * ```
15
15
  */
16
16
  import type { Component } from 'svelte';
17
- import { Callout, Accordion, AccordionItem, Tabs, Tab, Image, Video, Card, CardGrid, ImageCard, ImageCardGrid, Steps, Step, Icon, Mermaid, Math, Columns, Column, DocBadge, Tooltip, Frame, CodeBlock, Timeline, TimelineItem, ApiEndpoint, ApiParams, ApiResponse, ApiPlayground, ApiReference } from './components/docs';
18
- export { Callout, Accordion, AccordionItem, Tabs, Tab, Image, Video, Card, CardGrid, ImageCard, ImageCardGrid, Steps, Step, Icon, Mermaid, Math, Columns, Column, DocBadge, Tooltip, Frame, CodeBlock, Timeline, TimelineItem, ApiEndpoint, ApiParams, ApiResponse, ApiPlayground, ApiReference, };
17
+ import { Callout, Accordion, AccordionItem, Tabs, Tab, Image, Video, Card, CardGrid, ImageCard, ImageCardGrid, Steps, Step, Icon, Mermaid, Math, Columns, Column, DocBadge, Tooltip, Frame, CodeBlock, Timeline, TimelineItem, ApiEndpoint, ApiParams, ApiResponse, ApiPlayground, ApiReference, Changelog, Update } from './components/docs';
18
+ export { Callout, Accordion, AccordionItem, Tabs, Tab, Image, Video, Card, CardGrid, ImageCard, ImageCardGrid, Steps, Step, Icon, Mermaid, Math, Columns, Column, DocBadge, Tooltip, Frame, CodeBlock, Timeline, TimelineItem, ApiEndpoint, ApiParams, ApiResponse, ApiPlayground, ApiReference, Changelog, Update, };
19
19
  /**
20
20
  * Component map for passing to layout components that render MDX content.
21
21
  */
@@ -13,9 +13,9 @@
13
13
  * <Callout type="info">This is a callout</Callout>
14
14
  * ```
15
15
  */
16
- import { Callout, Accordion, AccordionItem, Tabs, Tab, Image, Video, Card, CardGrid, ImageCard, ImageCardGrid, Steps, Step, Icon, Mermaid, Math, Columns, Column, DocBadge, Tooltip, Frame, CodeBlock, Timeline, TimelineItem, ApiEndpoint, ApiParams, ApiResponse, ApiPlayground, ApiReference, } from './components/docs';
16
+ import { Callout, Accordion, AccordionItem, Tabs, Tab, Image, Video, Card, CardGrid, ImageCard, ImageCardGrid, Steps, Step, Icon, Mermaid, Math, Columns, Column, DocBadge, Tooltip, Frame, CodeBlock, Timeline, TimelineItem, ApiEndpoint, ApiParams, ApiResponse, ApiPlayground, ApiReference, Changelog, Update, } from './components/docs';
17
17
  // Re-export all MDX-usable components
18
- export { Callout, Accordion, AccordionItem, Tabs, Tab, Image, Video, Card, CardGrid, ImageCard, ImageCardGrid, Steps, Step, Icon, Mermaid, Math, Columns, Column, DocBadge, Tooltip, Frame, CodeBlock, Timeline, TimelineItem, ApiEndpoint, ApiParams, ApiResponse, ApiPlayground, ApiReference, };
18
+ export { Callout, Accordion, AccordionItem, Tabs, Tab, Image, Video, Card, CardGrid, ImageCard, ImageCardGrid, Steps, Step, Icon, Mermaid, Math, Columns, Column, DocBadge, Tooltip, Frame, CodeBlock, Timeline, TimelineItem, ApiEndpoint, ApiParams, ApiResponse, ApiPlayground, ApiReference, Changelog, Update, };
19
19
  /**
20
20
  * Component map for passing to layout components that render MDX content.
21
21
  */
@@ -50,4 +50,6 @@ export const mdxComponents = {
50
50
  ApiResponse,
51
51
  ApiPlayground,
52
52
  ApiReference,
53
+ Changelog,
54
+ Update,
53
55
  };
package/dist/mdx.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { BadgeInput } from "./badges";
1
2
  import type { I18nConfig } from "./config.types";
2
3
  /**
3
4
  * Structured node type for MDX content rendering.
@@ -12,6 +13,7 @@ export interface MdxNode {
12
13
  props?: Record<string, any>;
13
14
  children?: MdxNode[];
14
15
  }
16
+ export declare function processMarkdownToMdxNodes(markdown: string): Promise<MdxNode[]>;
15
17
  export interface DocMeta {
16
18
  title: string;
17
19
  description?: string;
@@ -34,6 +36,8 @@ export interface DocMeta {
34
36
  word_count?: number;
35
37
  icon?: string;
36
38
  tab_group?: string;
39
+ badge?: BadgeInput;
40
+ rss?: boolean;
37
41
  locale?: string;
38
42
  protected?: boolean;
39
43
  isProtected?: boolean;
@@ -51,6 +55,7 @@ export interface Doc {
51
55
  categoryCollapsed?: boolean;
52
56
  categoryIcon?: string;
53
57
  categoryTabGroup?: string;
58
+ categoryBadge?: BadgeInput;
54
59
  locale?: string;
55
60
  }
56
61
  export interface TocItem {
package/dist/mdx.js CHANGED
@@ -3,6 +3,8 @@ import path from "path";
3
3
  import matter from "gray-matter";
4
4
  import yaml from "js-yaml";
5
5
  import { rehypeBasePath } from "./rehype-base-path.js";
6
+ import { rehypeChangelogIds } from "./rehype-changelog-ids.js";
7
+ import { annotateChangelogNodes } from "./changelog.js";
6
8
  import { remarkCodeMeta } from "./remark-code-meta.js";
7
9
  import { unified } from "unified";
8
10
  import remarkParse from "remark-parse";
@@ -10,7 +12,7 @@ import remarkGfm from "remark-gfm";
10
12
  import remarkMath from "remark-math";
11
13
  import remarkRehype from "remark-rehype";
12
14
  import rehypeSlug from "rehype-slug";
13
- import { slug as githubSlug } from "github-slugger";
15
+ import GithubSlugger from "github-slugger";
14
16
  import rehypeRaw from "rehype-raw";
15
17
  import rehypeKatex from "rehype-katex";
16
18
  import rehypeStringify from "rehype-stringify";
@@ -67,6 +69,8 @@ const COMPONENT_TAG_MAP = {
67
69
  apiresponse: 'ApiResponse',
68
70
  apiplayground: 'ApiPlayground',
69
71
  apireference: 'ApiReference',
72
+ changelog: 'Changelog',
73
+ update: 'Update',
70
74
  };
71
75
  /**
72
76
  * Map of lowercased attribute names to their correct camelCase form.
@@ -1268,7 +1272,7 @@ function ensureComponentBlockIntegrity(markdown) {
1268
1272
  }
1269
1273
  return markdown;
1270
1274
  }
1271
- async function processMarkdownToMdxNodes(markdown) {
1275
+ export async function processMarkdownToMdxNodes(markdown) {
1272
1276
  // Mask pipes inside inline code spans so GFM tables containing
1273
1277
  // `{{ x | filter }}`-style code don't get their rows broken.
1274
1278
  const masked = maskInlineCodePipes(markdown);
@@ -1289,6 +1293,9 @@ async function processMarkdownToMdxNodes(markdown) {
1289
1293
  .use(remarkRehype, { allowDangerousHtml: true })
1290
1294
  .use(rehypeRaw)
1291
1295
  .use(rehypeSlug)
1296
+ // After rehypeSlug so the intent is obvious: replay the heading sequence
1297
+ // through a second slugger, then anchor each <Update> in that namespace.
1298
+ .use(rehypeChangelogIds)
1292
1299
  .use(rehypeKatex);
1293
1300
  if (basePath) {
1294
1301
  processor.use(rehypeBasePath, { basePath });
@@ -1299,6 +1306,10 @@ async function processMarkdownToMdxNodes(markdown) {
1299
1306
  const children = hast.children || [];
1300
1307
  const nodes = await hastChildrenToMdxNodes(children);
1301
1308
  restorePipeMarkersInNodes(nodes);
1309
+ // Union each <Changelog>'s child tags into `allTags`. Done here, on the
1310
+ // server, because children render after their parent during SSR — a filter
1311
+ // bar that waited for its updates to self-register would serialize empty.
1312
+ annotateChangelogNodes(nodes);
1302
1313
  return nodes;
1303
1314
  }
1304
1315
  /**
@@ -1566,6 +1577,7 @@ export function getAllDocs(version = "v1.0.0", locale, product) {
1566
1577
  doc.categoryCollapsed = categoryConfig.collapsed;
1567
1578
  doc.categoryIcon = categoryConfig.icon;
1568
1579
  doc.categoryTabGroup = categoryConfig.tab_group;
1580
+ doc.categoryBadge = categoryConfig.badge;
1569
1581
  }
1570
1582
  }
1571
1583
  return doc;
@@ -1710,26 +1722,52 @@ export function getAdjacentDocs(currentSlug, allDocs) {
1710
1722
  };
1711
1723
  }
1712
1724
  export function extractTableOfContents(content) {
1713
- const headingRegex = /^(#{2,3})\s+(.+)$/gm;
1725
+ // One regex over both kinds of anchor so they come back in document order.
1726
+ // group 1/2 — an ATX heading, at any level (see the slugger note below)
1727
+ // group 3 — the `label` of an <Update>, which anchors a changelog entry
1728
+ // `[^>]*?` (not `.`) spans newlines, because authors wrap long <Update> tags.
1729
+ const anchorRegex = /^(#{1,6})[ \t]+(.+)$|<update\b[^>]*?\blabel\s*=\s*["']([^"']+)["']/gim;
1714
1730
  const toc = [];
1731
+ // Mirror rehype-slug exactly: it builds ONE GithubSlugger per document and
1732
+ // slugs every h1–h6 in order, so a repeated heading renders as `setup` and
1733
+ // then `setup-1`. github-slugger's bare `slug()` export is stateless and
1734
+ // cannot know that, so it emitted `setup` twice and the second ToC link
1735
+ // silently scrolled to the first heading.
1736
+ //
1737
+ // The slugger must see headings the ToC never displays (h1, h4–h6) because
1738
+ // they still consume names from the same namespace — `# Setup` followed by
1739
+ // `## Setup` renders the h2 as `setup-1`.
1740
+ //
1741
+ // <Update> labels share this slugger too, mirroring rehype-changelog-ids.ts,
1742
+ // so a label can never collide with a heading of the same text.
1743
+ const slugger = new GithubSlugger();
1715
1744
  // Headings inside fenced code blocks are code, not headings. They get no
1716
- // `id` in the rendered page, so a ToC entry for them can only ever 404.
1745
+ // `id` in the rendered page, so a ToC entry for them can only ever 404
1746
+ // and rehype-slug never sees them either, so skipping them keeps the two
1747
+ // sluggers in lockstep. The same reasoning covers a fenced <Update> example.
1717
1748
  for (const { text: segment, isCode } of splitByCodeFences(content)) {
1718
1749
  if (isCode)
1719
1750
  continue;
1720
1751
  let match;
1721
- headingRegex.lastIndex = 0;
1722
- while ((match = headingRegex.exec(segment)) !== null) {
1723
- const level = match[1].length;
1724
- const text = match[2];
1725
- // Use the SAME slugger rehype-slug uses, rather than approximating it.
1726
- // The old hand-rolled version stripped `_` and trimmed a trailing `-`,
1727
- // while github-slugger keeps both — so every heading naming a snake_case
1728
- // identifier (`list_display`, `on_upload`, `cache_page`) produced a ToC
1729
- // link that pointed at nothing. Backticks are stripped first because
1730
- // rehype-slug slugs the RENDERED heading text, where `code` markup is
1731
- // already gone.
1732
- const id = githubSlug(text.replace(/`/g, ""));
1752
+ anchorRegex.lastIndex = 0;
1753
+ while ((match = anchorRegex.exec(segment)) !== null) {
1754
+ const [, hashes, headingText, updateLabel] = match;
1755
+ if (updateLabel !== undefined) {
1756
+ const label = updateLabel.trim();
1757
+ if (!label)
1758
+ continue;
1759
+ // Updates sit at the ToC's top level, alongside h2s.
1760
+ toc.push({ id: slugger.slug(label), title: label, level: 2 });
1761
+ continue;
1762
+ }
1763
+ const level = hashes.length;
1764
+ const text = headingText.trim();
1765
+ // Backticks are stripped first because rehype-slug slugs the RENDERED
1766
+ // heading text, where `code` markup is already gone. Always slug, even
1767
+ // for levels we drop, to keep the occurrence counters aligned.
1768
+ const id = slugger.slug(text.replace(/`/g, ""));
1769
+ if (level < 2 || level > 3)
1770
+ continue;
1733
1771
  toc.push({ id, title: text, level });
1734
1772
  }
1735
1773
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Rehype plugin that gives every `<Update>` a stable anchor id.
3
+ *
4
+ * The id is slugged from the update's `label`, using a slugger that has ALSO
5
+ * consumed every heading on the page. rehype-slug builds one GithubSlugger per
6
+ * document and feeds it every h1–h6 in order; by replaying that same sequence
7
+ * here we land in the same namespace. Two consequences:
8
+ *
9
+ * - An `<Update label="Changelog">` on a page that also has `## Changelog`
10
+ * becomes `changelog-1` instead of silently duplicating the heading's id.
11
+ * - Two updates sharing a label get `v1` and `v1-1`, never a collision.
12
+ *
13
+ * `extractTableOfContents` performs the same walk over the raw markdown, so the
14
+ * anchors it links to are byte-identical to the ones rendered here.
15
+ *
16
+ * Manually walks the tree to avoid ESM/CJS issues with unist-util-visit, matching
17
+ * rehype-base-path.ts.
18
+ */
19
+ import type { Root } from 'hast';
20
+ export declare function rehypeChangelogIds(): (tree: Root) => void;
@@ -0,0 +1,43 @@
1
+ import GithubSlugger from 'github-slugger';
2
+ const HEADING_TAG = /^h[1-6]$/;
3
+ /** Concatenate the text nodes beneath a node, as hast-util-to-string would. */
4
+ function textContent(node) {
5
+ if (node.type === 'text')
6
+ return String(node.value ?? '');
7
+ if (Array.isArray(node.children))
8
+ return node.children.map(textContent).join('');
9
+ return '';
10
+ }
11
+ /** Preorder walk over element nodes, i.e. document order. */
12
+ function walkElements(nodes, fn) {
13
+ for (const node of nodes) {
14
+ if (node.type !== 'element')
15
+ continue;
16
+ fn(node);
17
+ if (node.children) {
18
+ walkElements(node.children, fn);
19
+ }
20
+ }
21
+ }
22
+ export function rehypeChangelogIds() {
23
+ return (tree) => {
24
+ const slugger = new GithubSlugger();
25
+ walkElements(tree.children, (node) => {
26
+ // Consume the name rehype-slug already consumed for this heading, keeping
27
+ // the two occurrence counters aligned. The return value is discarded —
28
+ // rehype-slug owns the heading's actual id.
29
+ if (HEADING_TAG.test(node.tagName)) {
30
+ slugger.slug(textContent(node));
31
+ return;
32
+ }
33
+ if (node.tagName !== 'update')
34
+ return;
35
+ const label = node.properties?.label;
36
+ // A label-less update has no identity to anchor. It still renders; it just
37
+ // gets no anchor, no ToC entry and no feed item.
38
+ if (typeof label !== 'string' || !label.trim())
39
+ return;
40
+ node.properties = { ...node.properties, id: slugger.slug(label.trim()) };
41
+ });
42
+ };
43
+ }
@@ -3,10 +3,12 @@
3
3
  * This module provides consistent sidebar logic across the application
4
4
  * to ensure ordering is handled the same way everywhere.
5
5
  */
6
+ import type { BadgeInput } from "./badges.js";
6
7
  export interface SidebarGroup {
7
8
  label: string;
8
9
  path: string;
9
10
  icon?: string;
11
+ badge?: BadgeInput;
10
12
  items: any[];
11
13
  position: number;
12
14
  collapsible: boolean;
@@ -50,6 +52,7 @@ export declare function buildSidebarStructure<T extends {
50
52
  categoryLabel?: string;
51
53
  categoryPosition?: number;
52
54
  categoryIcon?: string;
55
+ categoryBadge?: BadgeInput;
53
56
  categoryCollapsible?: boolean;
54
57
  categoryCollapsed?: boolean;
55
58
  meta: any;
@@ -49,11 +49,14 @@ export function buildSidebarStructure(docs) {
49
49
  docs.forEach((doc) => {
50
50
  const pathParts = doc.filePath.split("/");
51
51
  const folderPath = pathParts.length > 1 ? pathParts.slice(0, -1).join("/") : "";
52
- if (folderPath && doc.categoryLabel) {
52
+ // A `_category_.json` may set a badge without setting a label, so keying
53
+ // this purely off `categoryLabel` would silently drop the badge.
54
+ if (folderPath && (doc.categoryLabel || doc.categoryBadge)) {
53
55
  categoryMetadata.set(folderPath, {
54
56
  label: doc.categoryLabel,
55
57
  position: doc.categoryPosition,
56
58
  icon: doc.categoryIcon,
59
+ badge: doc.categoryBadge,
57
60
  collapsible: doc.categoryCollapsible,
58
61
  collapsed: doc.categoryCollapsed
59
62
  });
@@ -100,6 +103,7 @@ export function buildSidebarStructure(docs) {
100
103
  label: metadata?.label ?? folderLabel,
101
104
  path: currentPath,
102
105
  icon: metadata?.icon,
106
+ badge: metadata?.badge,
103
107
  items: [],
104
108
  position: metadata?.position ?? 999,
105
109
  collapsible: metadata?.collapsible ?? true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specra",
3
- "version": "0.2.67",
3
+ "version": "0.2.68",
4
4
  "description": "A modern documentation library for SvelteKit with built-in versioning, API reference generation, full-text search, and MDX support",
5
5
  "svelte": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",