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.
@@ -2,9 +2,67 @@
2
2
  "$ref": "#/definitions/CategoryConfig",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "definitions": {
5
+ "BadgeColor": {
6
+ "enum": [
7
+ "gray",
8
+ "red",
9
+ "orange",
10
+ "amber",
11
+ "green",
12
+ "teal",
13
+ "blue",
14
+ "purple",
15
+ "pink",
16
+ "cyan"
17
+ ],
18
+ "type": "string"
19
+ },
20
+ "BadgeInput": {
21
+ "anyOf": [
22
+ {
23
+ "type": "string"
24
+ },
25
+ {
26
+ "$ref": "#/definitions/BadgeSpec"
27
+ },
28
+ {
29
+ "items": {
30
+ "anyOf": [
31
+ {
32
+ "type": "string"
33
+ },
34
+ {
35
+ "$ref": "#/definitions/BadgeSpec"
36
+ }
37
+ ]
38
+ },
39
+ "type": "array"
40
+ }
41
+ ],
42
+ "description": "Anything an author may write in frontmatter or `_category_.json`."
43
+ },
44
+ "BadgeSpec": {
45
+ "additionalProperties": false,
46
+ "description": "A badge written out longhand. `color` falls back to the preset, then to gray.",
47
+ "properties": {
48
+ "color": {
49
+ "$ref": "#/definitions/BadgeColor"
50
+ },
51
+ "text": {
52
+ "type": "string"
53
+ }
54
+ },
55
+ "required": [
56
+ "text"
57
+ ],
58
+ "type": "object"
59
+ },
5
60
  "CategoryConfig": {
6
61
  "additionalProperties": false,
7
62
  "properties": {
63
+ "badge": {
64
+ "$ref": "#/definitions/BadgeInput"
65
+ },
8
66
  "collapsed": {
9
67
  "type": "boolean"
10
68
  },
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Sidebar badge resolution.
3
+ *
4
+ * Authors mark a page or a category with a badge and a small pill renders next
5
+ * to its sidebar row. A badge is a short text plus a color, and the common
6
+ * cases ship as presets so the frontmatter stays a single word:
7
+ *
8
+ * badge: new # preset
9
+ * badge: { text: Beta, color: purple } # explicit
10
+ * badge: { text: "v3 only" } # free text, default color
11
+ * badge: [new, beta] # several pills on one row
12
+ *
13
+ * Both frontmatter (`meta.badge`) and `_category_.json` (`badge`) pass through
14
+ * `resolveBadges`, so the two surfaces can never drift apart.
15
+ */
16
+ export declare const BADGE_COLORS: readonly ["gray", "red", "orange", "amber", "green", "teal", "blue", "purple", "pink", "cyan"];
17
+ export type BadgeColor = (typeof BADGE_COLORS)[number];
18
+ export declare const DEFAULT_BADGE_COLOR: BadgeColor;
19
+ /** A badge written out longhand. `color` falls back to the preset, then to gray. */
20
+ export interface BadgeSpec {
21
+ text: string;
22
+ color?: BadgeColor;
23
+ }
24
+ /** Anything an author may write in frontmatter or `_category_.json`. */
25
+ export type BadgeInput = string | BadgeSpec | Array<string | BadgeSpec>;
26
+ /** A badge ready to render: color narrowed, classes attached. */
27
+ export interface ResolvedBadge {
28
+ text: string;
29
+ color: BadgeColor;
30
+ className: string;
31
+ }
32
+ /** Shorthand names an author can use in place of a `{ text, color }` pair. */
33
+ export declare const BADGE_PRESETS: Record<string, Required<BadgeSpec>>;
34
+ /**
35
+ * Normalize any author input into a render-ready list. Unparseable entries are
36
+ * dropped individually, so one bad badge never takes its siblings down with it.
37
+ */
38
+ export declare function resolveBadges(input: unknown): ResolvedBadge[];
package/dist/badges.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Sidebar badge resolution.
3
+ *
4
+ * Authors mark a page or a category with a badge and a small pill renders next
5
+ * to its sidebar row. A badge is a short text plus a color, and the common
6
+ * cases ship as presets so the frontmatter stays a single word:
7
+ *
8
+ * badge: new # preset
9
+ * badge: { text: Beta, color: purple } # explicit
10
+ * badge: { text: "v3 only" } # free text, default color
11
+ * badge: [new, beta] # several pills on one row
12
+ *
13
+ * Both frontmatter (`meta.badge`) and `_category_.json` (`badge`) pass through
14
+ * `resolveBadges`, so the two surfaces can never drift apart.
15
+ */
16
+ export const BADGE_COLORS = [
17
+ "gray",
18
+ "red",
19
+ "orange",
20
+ "amber",
21
+ "green",
22
+ "teal",
23
+ "blue",
24
+ "purple",
25
+ "pink",
26
+ "cyan",
27
+ ];
28
+ export const DEFAULT_BADGE_COLOR = "gray";
29
+ /**
30
+ * Tailwind scans this file (see the `@source` directive in `styles/globals.css`),
31
+ * so every class must appear here as a complete literal string. Composing them at
32
+ * runtime — `bg-${color}-500/10` — compiles to nothing.
33
+ *
34
+ * Gray leans on the theme tokens so it tracks the active preset theme; the rest
35
+ * are fixed palette colors, since a "Deprecated" pill should read the same red
36
+ * whichever theme the site ships.
37
+ */
38
+ const BADGE_COLOR_CLASSES = {
39
+ gray: "bg-muted text-muted-foreground",
40
+ red: "bg-red-500/10 text-red-600 dark:text-red-400",
41
+ orange: "bg-orange-500/10 text-orange-600 dark:text-orange-400",
42
+ amber: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
43
+ green: "bg-green-500/10 text-green-600 dark:text-green-400",
44
+ teal: "bg-teal-500/10 text-teal-600 dark:text-teal-400",
45
+ blue: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
46
+ purple: "bg-purple-500/10 text-purple-600 dark:text-purple-400",
47
+ pink: "bg-pink-500/10 text-pink-600 dark:text-pink-400",
48
+ cyan: "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400",
49
+ };
50
+ /** Shorthand names an author can use in place of a `{ text, color }` pair. */
51
+ export const BADGE_PRESETS = {
52
+ new: { text: "New", color: "green" },
53
+ updated: { text: "Updated", color: "blue" },
54
+ beta: { text: "Beta", color: "purple" },
55
+ experimental: { text: "Experimental", color: "orange" },
56
+ "pre-release": { text: "Pre-release", color: "amber" },
57
+ deprecated: { text: "Deprecated", color: "red" },
58
+ "coming-soon": { text: "Coming soon", color: "gray" },
59
+ };
60
+ /** `Pre_Release`, `PRE RELEASE` and `pre-release` all name the same preset. */
61
+ function presetKey(value) {
62
+ return value.trim().toLowerCase().replace(/[\s_]+/g, "-");
63
+ }
64
+ function isBadgeColor(value) {
65
+ return typeof value === "string" && BADGE_COLORS.includes(value);
66
+ }
67
+ function finalize(text, color) {
68
+ return { text, color, className: BADGE_COLOR_CLASSES[color] };
69
+ }
70
+ function resolveBadge(entry) {
71
+ if (typeof entry === "string") {
72
+ const preset = BADGE_PRESETS[presetKey(entry)];
73
+ if (preset)
74
+ return finalize(preset.text, preset.color);
75
+ const text = entry.trim();
76
+ return text ? finalize(text, DEFAULT_BADGE_COLOR) : null;
77
+ }
78
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) {
79
+ const { text, color } = entry;
80
+ if (typeof text !== "string")
81
+ return null;
82
+ const trimmed = text.trim();
83
+ if (!trimmed)
84
+ return null;
85
+ // `{ text: Beta }` still picks up the preset color; an explicit, valid
86
+ // `color` always wins. An unknown color degrades to gray rather than
87
+ // throwing, so one typo cannot fail a whole docs build.
88
+ const preset = BADGE_PRESETS[presetKey(trimmed)];
89
+ const resolved = isBadgeColor(color) ? color : (preset?.color ?? DEFAULT_BADGE_COLOR);
90
+ return finalize(trimmed, resolved);
91
+ }
92
+ return null;
93
+ }
94
+ /**
95
+ * Normalize any author input into a render-ready list. Unparseable entries are
96
+ * dropped individually, so one bad badge never takes its siblings down with it.
97
+ */
98
+ export function resolveBadges(input) {
99
+ if (input === null || input === undefined)
100
+ return [];
101
+ const entries = Array.isArray(input) ? input : [input];
102
+ const badges = [];
103
+ for (const entry of entries) {
104
+ const badge = resolveBadge(entry);
105
+ if (badge)
106
+ badges.push(badge);
107
+ }
108
+ return badges;
109
+ }
@@ -1,3 +1,4 @@
1
+ import type { BadgeInput } from "./badges.js";
1
2
  export interface CategoryConfig {
2
3
  label?: string;
3
4
  position?: number;
@@ -10,6 +11,7 @@ export interface CategoryConfig {
10
11
  collapsible?: boolean;
11
12
  icon?: string;
12
13
  tab_group?: string;
14
+ badge?: BadgeInput;
13
15
  }
14
16
  /**
15
17
  * Read category.json from a folder
@@ -0,0 +1,6 @@
1
+ export interface ChangelogContext {
2
+ /** True when an update carrying `tags` survives the current filter. */
3
+ matches(tags: string[] | undefined): boolean;
4
+ }
5
+ export declare function setChangelogContext(context: ChangelogContext): void;
6
+ export declare function getChangelogContext(): ChangelogContext | undefined;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Context shared between `<Changelog>` and its `<Update>` children.
3
+ *
4
+ * Siblings cannot see each other in Svelte 5, so tag filtering needs a common
5
+ * parent to hold the selected-tag set. `<Update>` works fine without one — a
6
+ * bare update outside a `<Changelog>` simply never gets filtered.
7
+ */
8
+ import { getContext, setContext } from 'svelte';
9
+ const CHANGELOG_KEY = Symbol('specra:changelog');
10
+ export function setChangelogContext(context) {
11
+ setContext(CHANGELOG_KEY, context);
12
+ }
13
+ export function getChangelogContext() {
14
+ return getContext(CHANGELOG_KEY);
15
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Changelog entry extraction and RSS feed generation.
3
+ *
4
+ * A changelog page is an ordinary MDX page holding `<Update>` blocks:
5
+ *
6
+ * <Changelog>
7
+ * <Update label="2026-07-10" description="v1.2.0" tags={["SDK"]}>
8
+ * Added sidebar badges.
9
+ * </Update>
10
+ * </Changelog>
11
+ *
12
+ * This module is framework-agnostic and server-safe (no fs, no Svelte). It
13
+ * turns the `MdxNode` tree into feed entries and renders RSS 2.0.
14
+ */
15
+ import type { MdxNode } from "./mdx.js";
16
+ /** Author-supplied override for how an update appears in the feed. */
17
+ export interface UpdateRssOverride {
18
+ title?: string;
19
+ description?: string;
20
+ }
21
+ export interface ChangelogEntry {
22
+ /** Anchor id, injected server-side so it matches the rendered element. */
23
+ id: string;
24
+ label: string;
25
+ description?: string;
26
+ tags: string[];
27
+ /** Feed entry title: `rss.title`, else the label. */
28
+ title: string;
29
+ /** Feed entry body: `rss.description`, else the entry's plain text. */
30
+ summary: string;
31
+ }
32
+ /**
33
+ * Pull every `<Update>` out of a processed document, in document order.
34
+ * Works whether or not the updates sit inside a `<Changelog>` wrapper.
35
+ */
36
+ export declare function extractChangelogEntries(nodes: MdxNode[] | undefined): ChangelogEntry[];
37
+ /**
38
+ * Inject `allTags` onto every `<Changelog>` node: the union of its updates' tags,
39
+ * in first-seen order.
40
+ *
41
+ * Computed here, on the server, rather than by having each `<Update>` register
42
+ * itself with a parent context at render time. Children render after their parent
43
+ * during SSR, so a self-registering filter bar would serialize with an empty tag
44
+ * list and then repopulate on hydration — a mismatch and a visible flash.
45
+ *
46
+ * Mutates in place and returns the same array, matching the pipeline's style.
47
+ */
48
+ export declare function annotateChangelogNodes(nodes: MdxNode[] | undefined): MdxNode[];
49
+ export interface RssFeedOptions {
50
+ entries: ChangelogEntry[];
51
+ /** Site origin, e.g. `https://docs.example.com` (from `config.site.url`). */
52
+ siteUrl: string;
53
+ /** Path of the changelog page, e.g. `/docs/v1.0.0/changelog`. */
54
+ pageUrl: string;
55
+ title: string;
56
+ description?: string;
57
+ }
58
+ /** Render entries as an RSS 2.0 document. */
59
+ export declare function renderRssFeed({ entries, siteUrl, pageUrl, title, description, }: RssFeedOptions): string;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Changelog entry extraction and RSS feed generation.
3
+ *
4
+ * A changelog page is an ordinary MDX page holding `<Update>` blocks:
5
+ *
6
+ * <Changelog>
7
+ * <Update label="2026-07-10" description="v1.2.0" tags={["SDK"]}>
8
+ * Added sidebar badges.
9
+ * </Update>
10
+ * </Changelog>
11
+ *
12
+ * This module is framework-agnostic and server-safe (no fs, no Svelte). It
13
+ * turns the `MdxNode` tree into feed entries and renders RSS 2.0.
14
+ */
15
+ /** The component names, as they appear on `MdxNode.name` after tag mapping. */
16
+ const UPDATE_NODE = "Update";
17
+ const CHANGELOG_NODE = "Changelog";
18
+ function isRecord(value) {
19
+ return !!value && typeof value === "object" && !Array.isArray(value);
20
+ }
21
+ function asStringArray(value) {
22
+ if (!Array.isArray(value))
23
+ return [];
24
+ return value.filter((v) => typeof v === "string" && v.trim().length > 0);
25
+ }
26
+ /**
27
+ * Tags that separate words. Everything else is inline and disappears without a
28
+ * trace: turning `<code>new</code>,` into a space would emit `new ,`, while
29
+ * deleting `</li><li>` outright would collapse `<li>a</li><li>b</li>` into `ab`.
30
+ */
31
+ const BLOCK_TAG = /<\/?(?:p|div|br|hr|h[1-6]|ul|ol|li|pre|blockquote|table|thead|tbody|tr|td|th|section|article|header|footer|figure|figcaption)\b[^>]*>/gi;
32
+ /**
33
+ * Strip an `html` node's markup down to readable text.
34
+ *
35
+ * Feed readers render a tiny, unpredictable subset of HTML, so entry bodies are
36
+ * plain text.
37
+ */
38
+ function htmlToText(html) {
39
+ return html
40
+ .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, " ")
41
+ .replace(BLOCK_TAG, " ")
42
+ .replace(/<[^>]+>/g, "")
43
+ .replace(/&nbsp;/gi, " ")
44
+ .replace(/&lt;/gi, "<")
45
+ .replace(/&gt;/gi, ">")
46
+ .replace(/&quot;/gi, '"')
47
+ .replace(/&#39;/gi, "'")
48
+ // `&amp;` last: decoding it earlier would let `&amp;lt;` become `<`.
49
+ .replace(/&amp;/gi, "&")
50
+ .replace(/\s+/g, " ")
51
+ .trim();
52
+ }
53
+ /**
54
+ * Collect plain text from a node's subtree.
55
+ *
56
+ * Component nodes are skipped, matching Mintlify: a `<Card>` or `<Tabs>` inside
57
+ * an update carries layout, not prose, and flattening it produces noise. Authors
58
+ * who need those words in the feed supply `rss={{ description: "..." }}`.
59
+ */
60
+ function nodesToText(nodes) {
61
+ if (!nodes?.length)
62
+ return "";
63
+ const parts = [];
64
+ for (const node of nodes) {
65
+ if (node.type === "html" && node.content)
66
+ parts.push(htmlToText(node.content));
67
+ }
68
+ return parts.filter(Boolean).join(" ").replace(/\s+/g, " ").trim();
69
+ }
70
+ /** Depth-first walk yielding every node in document order. */
71
+ function* walk(nodes) {
72
+ if (!nodes?.length)
73
+ return;
74
+ for (const node of nodes) {
75
+ yield node;
76
+ yield* walk(node.children);
77
+ }
78
+ }
79
+ function toEntry(node) {
80
+ const props = node.props ?? {};
81
+ const label = typeof props.label === "string" ? props.label.trim() : "";
82
+ // An update with no label has no anchor and no stable identity, so it cannot
83
+ // appear in a feed. It still renders on the page; it is just not syndicated.
84
+ if (!label)
85
+ return null;
86
+ const id = typeof props.id === "string" ? props.id : "";
87
+ const description = typeof props.description === "string" ? props.description.trim() : undefined;
88
+ const tags = asStringArray(props.tags);
89
+ const rss = isRecord(props.rss) ? props.rss : {};
90
+ const title = typeof rss.title === "string" && rss.title.trim() ? rss.title.trim() : label;
91
+ const summary = typeof rss.description === "string" && rss.description.trim()
92
+ ? rss.description.trim()
93
+ : nodesToText(node.children);
94
+ return { id, label, description, tags, title, summary };
95
+ }
96
+ /**
97
+ * Pull every `<Update>` out of a processed document, in document order.
98
+ * Works whether or not the updates sit inside a `<Changelog>` wrapper.
99
+ */
100
+ export function extractChangelogEntries(nodes) {
101
+ const entries = [];
102
+ for (const node of walk(nodes)) {
103
+ if (node.type !== "component" || node.name !== UPDATE_NODE)
104
+ continue;
105
+ const entry = toEntry(node);
106
+ if (entry)
107
+ entries.push(entry);
108
+ }
109
+ return entries;
110
+ }
111
+ /**
112
+ * Inject `allTags` onto every `<Changelog>` node: the union of its updates' tags,
113
+ * in first-seen order.
114
+ *
115
+ * Computed here, on the server, rather than by having each `<Update>` register
116
+ * itself with a parent context at render time. Children render after their parent
117
+ * during SSR, so a self-registering filter bar would serialize with an empty tag
118
+ * list and then repopulate on hydration — a mismatch and a visible flash.
119
+ *
120
+ * Mutates in place and returns the same array, matching the pipeline's style.
121
+ */
122
+ export function annotateChangelogNodes(nodes) {
123
+ if (!nodes?.length)
124
+ return nodes ?? [];
125
+ for (const node of walk(nodes)) {
126
+ if (node.type !== "component" || node.name !== CHANGELOG_NODE)
127
+ continue;
128
+ const seen = new Set();
129
+ const allTags = [];
130
+ for (const child of walk(node.children)) {
131
+ if (child.type !== "component" || child.name !== UPDATE_NODE)
132
+ continue;
133
+ for (const tag of asStringArray(child.props?.tags)) {
134
+ if (seen.has(tag))
135
+ continue;
136
+ seen.add(tag);
137
+ allTags.push(tag);
138
+ }
139
+ }
140
+ node.props = { ...node.props, allTags };
141
+ }
142
+ return nodes;
143
+ }
144
+ const XML_ESCAPES = {
145
+ "&": "&amp;",
146
+ "<": "&lt;",
147
+ ">": "&gt;",
148
+ '"': "&quot;",
149
+ "'": "&apos;",
150
+ };
151
+ function escapeXml(value) {
152
+ return value.replace(/[&<>"']/g, (char) => XML_ESCAPES[char]);
153
+ }
154
+ /**
155
+ * Interpret an update label as a date for `<pubDate>`.
156
+ *
157
+ * Labels are free text — "March 2025" and "v2 launch" are both valid — so a
158
+ * label that isn't a date simply yields no pubDate rather than an Invalid Date.
159
+ */
160
+ function toPubDate(label) {
161
+ const parsed = new Date(label);
162
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toUTCString();
163
+ }
164
+ /** Join a site origin and a page path into one absolute URL, without doubling slashes. */
165
+ function absoluteUrl(siteUrl, pageUrl) {
166
+ return `${siteUrl.replace(/\/+$/, "")}/${pageUrl.replace(/^\/+/, "")}`;
167
+ }
168
+ /** Render entries as an RSS 2.0 document. */
169
+ export function renderRssFeed({ entries, siteUrl, pageUrl, title, description = "", }) {
170
+ const link = absoluteUrl(siteUrl, pageUrl);
171
+ const feedUrl = `${link}/rss.xml`;
172
+ const items = entries.map((entry) => {
173
+ const guid = entry.id ? `${link}#${entry.id}` : link;
174
+ const pubDate = toPubDate(entry.label);
175
+ // `description` is the version line; fold it into the body so subscribers
176
+ // see which release an entry belongs to.
177
+ const body = [entry.description, entry.summary].filter(Boolean).join(" — ");
178
+ return [
179
+ " <item>",
180
+ ` <title>${escapeXml(entry.title)}</title>`,
181
+ ` <link>${escapeXml(guid)}</link>`,
182
+ ` <guid isPermaLink="false">${escapeXml(guid)}</guid>`,
183
+ pubDate ? ` <pubDate>${escapeXml(pubDate)}</pubDate>` : null,
184
+ ...entry.tags.map((tag) => ` <category>${escapeXml(tag)}</category>`),
185
+ ` <description>${escapeXml(body)}</description>`,
186
+ " </item>",
187
+ ]
188
+ .filter(Boolean)
189
+ .join("\n");
190
+ });
191
+ return [
192
+ '<?xml version="1.0" encoding="UTF-8"?>',
193
+ '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
194
+ " <channel>",
195
+ ` <title>${escapeXml(title)}</title>`,
196
+ ` <link>${escapeXml(link)}</link>`,
197
+ ` <description>${escapeXml(description)}</description>`,
198
+ ` <atom:link href="${escapeXml(feedUrl)}" rel="self" type="application/rss+xml" />`,
199
+ ...items,
200
+ " </channel>",
201
+ "</rss>",
202
+ "",
203
+ ].join("\n");
204
+ }
@@ -0,0 +1,71 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { setChangelogContext } from '../../changelog-context.js';
4
+
5
+ interface Props {
6
+ /**
7
+ * Union of every child <Update>'s tags, injected server-side by
8
+ * annotateChangelogNodes. Not authored by hand: children render after their
9
+ * parent during SSR, so a bar that waited for them to self-register would
10
+ * serialize empty and then flash on hydration.
11
+ */
12
+ allTags?: string[];
13
+ children?: Snippet;
14
+ }
15
+
16
+ let { allTags = [], children }: Props = $props();
17
+
18
+ let selected = $state<string[]>([]);
19
+
20
+ setChangelogContext({
21
+ matches(tags) {
22
+ // Reading `selected` here is what subscribes each <Update> to the filter.
23
+ if (selected.length === 0) return true;
24
+ if (!tags?.length) return false;
25
+ // AND logic: an update must carry every selected tag, not merely one.
26
+ return selected.every((tag) => tags.includes(tag));
27
+ }
28
+ });
29
+
30
+ function toggle(tag: string) {
31
+ selected = selected.includes(tag)
32
+ ? selected.filter((t) => t !== tag)
33
+ : [...selected, tag];
34
+ }
35
+ </script>
36
+
37
+ <div class="specra-changelog">
38
+ {#if allTags.length > 0}
39
+ <div class="mb-8 flex flex-wrap items-center gap-2 border-b border-border pb-6">
40
+ <span class="mr-1 text-xs font-medium text-muted-foreground">Filter</span>
41
+
42
+ {#each allTags as tag (tag)}
43
+ {@const active = selected.includes(tag)}
44
+ <button
45
+ type="button"
46
+ aria-pressed={active}
47
+ onclick={() => toggle(tag)}
48
+ class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {active
49
+ ? 'border-primary bg-primary/10 text-primary'
50
+ : 'border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground'}"
51
+ >
52
+ {tag}
53
+ </button>
54
+ {/each}
55
+
56
+ {#if selected.length > 0}
57
+ <button
58
+ type="button"
59
+ onclick={() => (selected = [])}
60
+ class="ml-1 text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
61
+ >
62
+ Clear
63
+ </button>
64
+ {/if}
65
+ </div>
66
+ {/if}
67
+
68
+ {#if children}
69
+ {@render children()}
70
+ {/if}
71
+ </div>
@@ -0,0 +1,14 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ /**
4
+ * Union of every child <Update>'s tags, injected server-side by
5
+ * annotateChangelogNodes. Not authored by hand: children render after their
6
+ * parent during SSR, so a bar that waited for them to self-register would
7
+ * serialize empty and then flash on hydration.
8
+ */
9
+ allTags?: string[];
10
+ children?: Snippet;
11
+ }
12
+ declare const Changelog: import("svelte").Component<Props, {}, "">;
13
+ type Changelog = ReturnType<typeof Changelog>;
14
+ export default Changelog;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { SpecraConfig } from '../../config.types.js';
3
+ import type { BadgeInput } from '../../badges.js';
3
4
  import SidebarMenuItems from './SidebarMenuItems.svelte';
4
5
 
5
6
  interface DocItem {
@@ -16,9 +17,11 @@
16
17
  categoryCollapsed?: boolean;
17
18
  categoryIcon?: string;
18
19
  categoryTabGroup?: string;
20
+ categoryBadge?: BadgeInput;
19
21
  meta?: {
20
22
  icon?: string;
21
23
  tab_group?: string;
24
+ badge?: BadgeInput;
22
25
  [key: string]: any;
23
26
  };
24
27
  }
@@ -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,9 +14,11 @@ 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;
21
+ badge?: BadgeInput;
19
22
  [key: string]: any;
20
23
  };
21
24
  }
@@ -0,0 +1,15 @@
1
+ <script lang="ts">
2
+ import type { ResolvedBadge } from '../../badges.js';
3
+
4
+ interface Props {
5
+ badge: ResolvedBadge;
6
+ }
7
+
8
+ let { badge }: Props = $props();
9
+ </script>
10
+
11
+ <span
12
+ class="px-1.5 py-0.5 text-[10px] font-medium rounded-full leading-none whitespace-nowrap {badge.className}"
13
+ >
14
+ {badge.text}
15
+ </span>
@@ -0,0 +1,7 @@
1
+ import type { ResolvedBadge } from '../../badges.js';
2
+ interface Props {
3
+ badge: ResolvedBadge;
4
+ }
5
+ declare const SidebarBadge: import("svelte").Component<Props, {}, "">;
6
+ type SidebarBadge = ReturnType<typeof SidebarBadge>;
7
+ export default SidebarBadge;