specra 0.2.66 → 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,6 +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";
15
+ import GithubSlugger from "github-slugger";
13
16
  import rehypeRaw from "rehype-raw";
14
17
  import rehypeKatex from "rehype-katex";
15
18
  import rehypeStringify from "rehype-stringify";
@@ -66,6 +69,8 @@ const COMPONENT_TAG_MAP = {
66
69
  apiresponse: 'ApiResponse',
67
70
  apiplayground: 'ApiPlayground',
68
71
  apireference: 'ApiReference',
72
+ changelog: 'Changelog',
73
+ update: 'Update',
69
74
  };
70
75
  /**
71
76
  * Map of lowercased attribute names to their correct camelCase form.
@@ -170,11 +175,92 @@ function maskInlineCodePipes(markdown) {
170
175
  }).join('');
171
176
  }
172
177
  /**
173
- * Restore `PIPE_MARKER` back to `|` in a string. No-op if the marker
174
- * isn't present (the common case), so cheap to call blanket.
178
+ * Private Use Area characters used to mask `<` and `>` written inside code
179
+ * (inline spans or fenced blocks) that sits within a component block.
180
+ *
181
+ * Why: `ensureComponentBlockIntegrity` deliberately collapses blank lines so
182
+ * that an entire `<Callout>…</Callout>` stays one CommonMark HTML block. But
183
+ * inside an HTML block, markdown no longer applies — code spans and fences stop
184
+ * being code — so a documented tag such as
185
+ * An inline `<script src="/x.js">` tag.
186
+ * is handed to rehype-raw as *real HTML*. parse5 opens a raw-text `<script>`
187
+ * element that is never closed and swallows every following sibling, so the
188
+ * next heading silently disappears from the page and its ToC link 404s.
189
+ *
190
+ * Masking the angle brackets before remark sees them keeps the block inert for
191
+ * parse5, and `restoreCodeMarkers` puts them back after the component's
192
+ * children have been re-parsed as markdown. PUA chars pass through
193
+ * remark/rehype unchanged and are never HTML-escaped — same trick as
194
+ * `PIPE_MARKER` above.
195
+ */
196
+ const LT_MARKER = '';
197
+ const GT_MARKER = '';
198
+ /**
199
+ * Mask `<` and `>` inside fenced code blocks and single-line inline code spans.
200
+ * Applied only to the interior of component blocks, where markdown code markers
201
+ * lose their meaning. Component tags themselves live outside code and are left
202
+ * alone, so `<Callout>` still parses as a component.
203
+ */
204
+ function maskCodeAngleBrackets(markdown) {
205
+ const maskAngles = (s) => s.replace(/</g, LT_MARKER).replace(/>/g, GT_MARKER);
206
+ return splitByCodeFences(markdown).map(({ text, isCode }) => {
207
+ if (isCode)
208
+ return maskAngles(text);
209
+ let result = '';
210
+ let i = 0;
211
+ while (i < text.length) {
212
+ if (text[i] !== '`') {
213
+ result += text[i];
214
+ i++;
215
+ continue;
216
+ }
217
+ let openLen = 0;
218
+ while (i + openLen < text.length && text[i + openLen] === '`')
219
+ openLen++;
220
+ let j = i + openLen;
221
+ let closeIdx = -1;
222
+ while (j < text.length && text[j] !== '\n') {
223
+ if (text[j] === '`') {
224
+ let closeLen = 0;
225
+ while (j + closeLen < text.length && text[j + closeLen] === '`')
226
+ closeLen++;
227
+ if (closeLen === openLen) {
228
+ closeIdx = j;
229
+ break;
230
+ }
231
+ j += closeLen;
232
+ }
233
+ else {
234
+ j++;
235
+ }
236
+ }
237
+ if (closeIdx === -1) {
238
+ result += text.slice(i, i + openLen);
239
+ i += openLen;
240
+ }
241
+ else {
242
+ const content = maskAngles(text.slice(i + openLen, closeIdx));
243
+ result += text.slice(i, i + openLen) + content + text.slice(closeIdx, closeIdx + openLen);
244
+ i = closeIdx + openLen;
245
+ }
246
+ }
247
+ return result;
248
+ }).join('');
249
+ }
250
+ /**
251
+ * Restore `PIPE_MARKER` back to `|`, and `LT_MARKER`/`GT_MARKER` back to
252
+ * `<`/`>`, in a string. No-op if no marker is present (the common case), so
253
+ * cheap to call blanket.
175
254
  */
176
255
  function restorePipeMarkers(s) {
177
- return s.indexOf(PIPE_MARKER) === -1 ? s : s.split(PIPE_MARKER).join('|');
256
+ let out = s;
257
+ if (out.indexOf(PIPE_MARKER) !== -1)
258
+ out = out.split(PIPE_MARKER).join('|');
259
+ if (out.indexOf(LT_MARKER) !== -1)
260
+ out = out.split(LT_MARKER).join('<');
261
+ if (out.indexOf(GT_MARKER) !== -1)
262
+ out = out.split(GT_MARKER).join('>');
263
+ return out;
178
264
  }
179
265
  /**
180
266
  * Walk an MdxNode tree and restore `PIPE_MARKER` to `|` in every string
@@ -1168,7 +1254,14 @@ function ensureComponentBlockIntegrity(markdown) {
1168
1254
  // code fences. The code fence content is raw text inside the HTML block and
1169
1255
  // will be re-processed by processComponentChildren through the markdown
1170
1256
  // pipeline, which restores proper code formatting.
1171
- const collapsed = block.replace(/^\s*$/gm, '<!-- -->');
1257
+ // Mask `<`/`>` written inside code (inline spans and fences) BEFORE the
1258
+ // blank-line collapse turns this whole block into one raw HTML block.
1259
+ // Once it is raw HTML, markdown code markers no longer protect their
1260
+ // contents, and a documented tag like `<script src="...">` reaches parse5
1261
+ // as real HTML — a raw-text element that never closes and swallows every
1262
+ // sibling after the component, heading and all. Restored by
1263
+ // `restorePipeMarkers` once the children are re-parsed as markdown.
1264
+ const collapsed = maskCodeAngleBrackets(block).replace(/^\s*$/gm, '<!-- -->');
1172
1265
  result += markdown.slice(lastIndex, blockStart) + collapsed;
1173
1266
  lastIndex = blockEnd;
1174
1267
  openTagRegex.lastIndex = blockEnd;
@@ -1179,7 +1272,7 @@ function ensureComponentBlockIntegrity(markdown) {
1179
1272
  }
1180
1273
  return markdown;
1181
1274
  }
1182
- async function processMarkdownToMdxNodes(markdown) {
1275
+ export async function processMarkdownToMdxNodes(markdown) {
1183
1276
  // Mask pipes inside inline code spans so GFM tables containing
1184
1277
  // `{{ x | filter }}`-style code don't get their rows broken.
1185
1278
  const masked = maskInlineCodePipes(markdown);
@@ -1200,6 +1293,9 @@ async function processMarkdownToMdxNodes(markdown) {
1200
1293
  .use(remarkRehype, { allowDangerousHtml: true })
1201
1294
  .use(rehypeRaw)
1202
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)
1203
1299
  .use(rehypeKatex);
1204
1300
  if (basePath) {
1205
1301
  processor.use(rehypeBasePath, { basePath });
@@ -1210,6 +1306,10 @@ async function processMarkdownToMdxNodes(markdown) {
1210
1306
  const children = hast.children || [];
1211
1307
  const nodes = await hastChildrenToMdxNodes(children);
1212
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);
1213
1313
  return nodes;
1214
1314
  }
1215
1315
  /**
@@ -1477,6 +1577,7 @@ export function getAllDocs(version = "v1.0.0", locale, product) {
1477
1577
  doc.categoryCollapsed = categoryConfig.collapsed;
1478
1578
  doc.categoryIcon = categoryConfig.icon;
1479
1579
  doc.categoryTabGroup = categoryConfig.tab_group;
1580
+ doc.categoryBadge = categoryConfig.badge;
1480
1581
  }
1481
1582
  }
1482
1583
  return doc;
@@ -1621,19 +1722,54 @@ export function getAdjacentDocs(currentSlug, allDocs) {
1621
1722
  };
1622
1723
  }
1623
1724
  export function extractTableOfContents(content) {
1624
- 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;
1625
1730
  const toc = [];
1626
- let match;
1627
- while ((match = headingRegex.exec(content)) !== null) {
1628
- const level = match[1].length;
1629
- const text = match[2];
1630
- // Generate ID the same way rehype-slug does
1631
- const id = text
1632
- .toLowerCase()
1633
- .replace(/\s+/g, "-") // Replace spaces with hyphens first
1634
- .replace(/[^a-z0-9-]/g, "") // Remove special chars (dots, slashes, etc)
1635
- .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
1636
- toc.push({ id, title: text, level });
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();
1744
+ // Headings inside fenced code blocks are code, not headings. They get no
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.
1748
+ for (const { text: segment, isCode } of splitByCodeFences(content)) {
1749
+ if (isCode)
1750
+ continue;
1751
+ let match;
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;
1771
+ toc.push({ id, title: text, level });
1772
+ }
1637
1773
  }
1638
1774
  return toc;
1639
1775
  }
@@ -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;