specra 0.2.7 → 0.2.8

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.
@@ -17,6 +17,8 @@ import remarkMath from 'remark-math'
17
17
  import rehypeSlug from 'rehype-slug'
18
18
  import rehypeKatex from 'rehype-katex'
19
19
  import rehypeRaw from 'rehype-raw'
20
+ import fs from 'fs'
21
+ import path from 'path'
20
22
 
21
23
  /**
22
24
  * Get mdsvex preprocessor config with all Specra remark/rehype plugins
@@ -42,11 +44,33 @@ export function specraMdsvexConfig(options = {}) {
42
44
  }
43
45
  }
44
46
 
47
+ /**
48
+ * Scan the docs/ directory and return prerender entries for all version root pages.
49
+ * This ensures adapter-static discovers and prerenders every version, not just the active one.
50
+ */
51
+ function discoverVersionEntries(docsDir = path.join(process.cwd(), 'docs')) {
52
+ const entries = ['/']
53
+ try {
54
+ if (!fs.existsSync(docsDir)) return entries
55
+
56
+ const items = fs.readdirSync(docsDir, { withFileTypes: true })
57
+ for (const item of items) {
58
+ if (item.isDirectory() && /^v\d/.test(item.name)) {
59
+ entries.push(`/docs/${item.name}`)
60
+ }
61
+ }
62
+ } catch {
63
+ // Ignore errors — fall back to just '/'
64
+ }
65
+ return entries
66
+ }
67
+
45
68
  /**
46
69
  * Create a full SvelteKit config with Specra defaults
47
70
  */
48
71
  export function specraConfig(options = {}) {
49
72
  const { vitePreprocess } = options.vitePreprocess || {}
73
+ const userPrerender = options.kit?.prerender || {}
50
74
 
51
75
  return {
52
76
  extensions: ['.svelte', '.md', '.svx', '.mdx'],
@@ -55,7 +79,14 @@ export function specraConfig(options = {}) {
55
79
  mdsvex(specraMdsvexConfig(options.mdsvex || {}))
56
80
  ],
57
81
  kit: {
58
- ...(options.kit || {})
82
+ ...options.kit,
83
+ prerender: {
84
+ handleHttpError: 'warn',
85
+ handleMissingId: 'warn',
86
+ handleUnseenRoutes: 'warn',
87
+ entries: discoverVersionEntries(),
88
+ ...userPrerender,
89
+ }
59
90
  }
60
91
  }
61
92
  }
@@ -55,8 +55,12 @@
55
55
  const baseUrl = $derived(
56
56
  product && product !== '_default_'
57
57
  ? `/docs/${product}`
58
- : (config.site?.baseUrl?.replace(/\/$/, '') || '')
58
+ : '/docs'
59
59
  );
60
+
61
+ // Note: We always use '/docs' as the base for non-product routes.
62
+ // Do NOT use config.site?.baseUrl here — that field (e.g. "/") refers to
63
+ // the site root, not the docs route prefix.
60
64
  </script>
61
65
 
62
66
  <div class="space-y-8">
@@ -26,7 +26,9 @@
26
26
  {#if Comp}
27
27
  {#if node.children && node.children.length > 0}
28
28
  <svelte:component this={Comp} {...node.props}>
29
- <MdxContent nodes={node.children} {components} />
29
+ {#snippet children()}
30
+ <MdxContent nodes={node.children} {components} />
31
+ {/snippet}
30
32
  </svelte:component>
31
33
  {:else}
32
34
  <svelte:component this={Comp} {...node.props} />
@@ -28,7 +28,8 @@
28
28
  let inputEl = $state<HTMLInputElement | null>(null);
29
29
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
30
30
 
31
- const baseUrl = $derived(config.site?.baseUrl || '/');
31
+ const siteBaseUrl = $derived(config.site?.baseUrl || '/');
32
+ const docsBaseUrl = '/docs';
32
33
 
33
34
  $effect(() => {
34
35
  if (isOpen && inputEl) {
@@ -75,7 +76,7 @@
75
76
  debounceTimer = setTimeout(async () => {
76
77
  try {
77
78
  const response = await fetch(
78
- `${baseUrl.replace(/\/$/, '')}/api/search?q=${encodeURIComponent(value.trim())}`
79
+ `${siteBaseUrl.replace(/\/$/, '')}/api/search?q=${encodeURIComponent(value.trim())}`
79
80
  );
80
81
  if (response.ok) {
81
82
  const data = await response.json();
@@ -93,7 +94,7 @@
93
94
 
94
95
  function navigateToResult(result: SearchResult) {
95
96
  const version = result.version || config.site?.activeVersion || 'v1';
96
- const url = `${baseUrl.replace(/\/$/, '')}/${version}/${result.slug}`;
97
+ const url = `${docsBaseUrl}/${version}/${result.slug}`;
97
98
  goto(url);
98
99
  onClose();
99
100
  }
@@ -57,7 +57,28 @@
57
57
  : `/docs/${version}`
58
58
  );
59
59
 
60
- let collapsed: Record<string, boolean> = $state({});
60
+ const STORAGE_KEY = 'specra-sidebar-collapsed';
61
+
62
+ function loadCollapsedState(): Record<string, boolean> {
63
+ if (typeof window === 'undefined') return {};
64
+ try {
65
+ const stored = localStorage.getItem(STORAGE_KEY);
66
+ return stored ? JSON.parse(stored) : {};
67
+ } catch {
68
+ return {};
69
+ }
70
+ }
71
+
72
+ function saveCollapsedState(state: Record<string, boolean>) {
73
+ if (typeof window === 'undefined') return;
74
+ try {
75
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
76
+ } catch {
77
+ // localStorage unavailable
78
+ }
79
+ }
80
+
81
+ let collapsed: Record<string, boolean> = $state(loadCollapsedState());
61
82
  let pathname = $derived($page.url.pathname.replace(/\/$/, ''));
62
83
 
63
84
  // Filter docs by active tab group if tab groups are configured
@@ -180,6 +201,7 @@
180
201
 
181
202
  function toggleSection(section: string) {
182
203
  collapsed = { ...collapsed, [section]: !collapsed[section] };
204
+ saveCollapsedState(collapsed);
183
205
  }
184
206
 
185
207
  function isActiveInGroup(group: SidebarGroup): boolean {
@@ -13,62 +13,54 @@
13
13
  }
14
14
 
15
15
  let { title = 'Parameters', params }: Props = $props();
16
+
17
+ let filteredParams = $derived(
18
+ Array.isArray(params) ? params.filter(p => p && p.name) : []
19
+ );
16
20
  </script>
17
21
 
18
- {#if params && params.length > 0}
22
+ {#if filteredParams.length > 0}
19
23
  <div class="mb-6">
20
24
  <h4 class="text-sm font-semibold text-foreground mb-3">{title}</h4>
21
- <div class="overflow-x-auto">
22
- <table class="w-full border-collapse">
25
+ <div class="specra-params-table">
26
+ <table>
23
27
  <thead>
24
- <tr class="border-b border-border">
25
- <th class="text-left py-2 px-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
26
- Property
27
- </th>
28
- <th class="text-left py-2 px-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
29
- Type
30
- </th>
31
- <th class="text-left py-2 px-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
32
- Required
33
- </th>
34
- <th class="text-left py-2 px-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
35
- Default
36
- </th>
37
- <th class="text-left py-2 px-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
38
- Description
39
- </th>
28
+ <tr>
29
+ <th>Property</th>
30
+ <th>Type</th>
31
+ <th>Required</th>
32
+ <th>Default</th>
33
+ <th>Description</th>
40
34
  </tr>
41
35
  </thead>
42
36
  <tbody>
43
- {#each params as param, index}
44
- <tr
45
- class={index !== params.length - 1 ? 'border-b border-border/50' : ''}
46
- >
47
- <td class="py-2.5 px-3">
48
- <code class="text-sm font-mono text-foreground">{param.name}</code>
37
+ {#each filteredParams as param, index}
38
+ <tr>
39
+ <td>
40
+ <code>{param.name}</code>
49
41
  </td>
50
- <td class="py-2.5 px-3">
51
- <span class="text-sm text-muted-foreground font-mono">{param.type}</span>
42
+ <td>
43
+ <span class="font-mono">{param.type}</span>
52
44
  </td>
53
- <td class="py-2.5 px-3">
45
+ <td>
54
46
  {#if param.required}
55
- <span class="text-sm text-red-600 dark:text-red-400">Yes</span>
47
+ <span class="text-red-600 dark:text-red-400">Yes</span>
56
48
  {:else}
57
- <span class="text-sm text-muted-foreground">No</span>
49
+ No
58
50
  {/if}
59
51
  </td>
60
- <td class="py-2.5 px-3">
52
+ <td>
61
53
  {#if param.default}
62
- <code class="text-sm font-mono text-muted-foreground">{param.default}</code>
54
+ <code>{param.default}</code>
63
55
  {:else}
64
- <span class="text-sm text-muted-foreground">-</span>
56
+ -
65
57
  {/if}
66
58
  </td>
67
- <td class="py-2.5 px-3">
59
+ <td>
68
60
  {#if param.description}
69
- <span class="text-sm text-muted-foreground">{param.description}</span>
61
+ {param.description}
70
62
  {:else}
71
- <span class="text-sm text-muted-foreground">-</span>
63
+ -
72
64
  {/if}
73
65
  </td>
74
66
  </tr>
@@ -78,3 +70,69 @@
78
70
  </div>
79
71
  </div>
80
72
  {/if}
73
+
74
+ <style>
75
+ .specra-params-table table {
76
+ border-collapse: separate;
77
+ border-spacing: 0;
78
+ border: 1px solid var(--border);
79
+ border-radius: 0.75rem;
80
+ overflow: hidden;
81
+ width: max-content;
82
+ max-width: 100%;
83
+ display: block;
84
+ overflow-x: auto;
85
+ -webkit-overflow-scrolling: touch;
86
+ }
87
+
88
+ .specra-params-table thead {
89
+ background: var(--muted);
90
+ }
91
+
92
+ .specra-params-table thead th {
93
+ border-bottom: 1px solid var(--border);
94
+ border-right: 1px solid var(--border);
95
+ padding: 0.625rem 1rem;
96
+ font-weight: 600;
97
+ font-size: 0.75rem;
98
+ text-transform: uppercase;
99
+ letter-spacing: 0.05em;
100
+ text-align: left;
101
+ color: var(--muted-foreground);
102
+ white-space: nowrap;
103
+ }
104
+
105
+ .specra-params-table thead th:last-child {
106
+ border-right: none;
107
+ }
108
+
109
+ .specra-params-table tbody td {
110
+ border-bottom: 1px solid var(--border);
111
+ border-right: 1px solid var(--border);
112
+ padding: 0.625rem 1rem;
113
+ font-size: 0.875rem;
114
+ color: var(--muted-foreground);
115
+ }
116
+
117
+ .specra-params-table tbody td:last-child {
118
+ border-right: none;
119
+ }
120
+
121
+ .specra-params-table tbody tr:last-child td {
122
+ border-bottom: none;
123
+ }
124
+
125
+ .specra-params-table tbody tr:hover {
126
+ background: var(--muted);
127
+ }
128
+
129
+ .specra-params-table code {
130
+ font-size: 0.8125rem;
131
+ font-family: var(--font-mono, ui-monospace, monospace);
132
+ color: var(--foreground);
133
+ background: var(--muted);
134
+ padding: 0.125rem 0.375rem;
135
+ border-radius: 0.25rem;
136
+ border: 1px solid var(--border);
137
+ }
138
+ </style>
@@ -36,23 +36,42 @@
36
36
 
37
37
  <script lang="ts">
38
38
  import { cn } from '../../utils.js';
39
- import type { HTMLButtonAttributes } from 'svelte/elements';
39
+ import type { HTMLButtonAttributes, HTMLAnchorAttributes } from 'svelte/elements';
40
40
  import type { Snippet } from 'svelte';
41
41
 
42
- interface Props extends HTMLButtonAttributes {
42
+ type ButtonProps = HTMLButtonAttributes & {
43
+ href?: never;
44
+ };
45
+
46
+ type AnchorProps = HTMLAnchorAttributes & {
47
+ href: string;
48
+ };
49
+
50
+ type Props = (ButtonProps | AnchorProps) & {
43
51
  variant?: ButtonVariants['variant'];
44
52
  size?: ButtonVariants['size'];
45
53
  class?: string;
46
54
  children?: Snippet;
47
- }
55
+ };
48
56
 
49
- let { variant = 'default', size = 'default', class: className, children, ...restProps }: Props = $props();
57
+ let { variant = 'default', size = 'default', class: className, children, href, ...restProps }: Props = $props();
50
58
  </script>
51
59
 
52
- <button
53
- data-slot="button"
54
- class={cn(buttonVariants({ variant, size, className }))}
55
- {...restProps}
56
- >
57
- {@render children?.()}
58
- </button>
60
+ {#if href}
61
+ <a
62
+ {href}
63
+ data-slot="button"
64
+ class={cn(buttonVariants({ variant, size, className }))}
65
+ {...restProps}
66
+ >
67
+ {@render children?.()}
68
+ </a>
69
+ {:else}
70
+ <button
71
+ data-slot="button"
72
+ class={cn(buttonVariants({ variant, size, className }))}
73
+ {...restProps}
74
+ >
75
+ {@render children?.()}
76
+ </button>
77
+ {/if}
@@ -4,14 +4,20 @@ export declare const buttonVariants: (props?: ({
4
4
  size?: "icon" | "default" | "sm" | "lg" | "icon-sm" | "icon-lg" | null | undefined;
5
5
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
6
6
  export type ButtonVariants = VariantProps<typeof buttonVariants>;
7
- import type { HTMLButtonAttributes } from 'svelte/elements';
7
+ import type { HTMLButtonAttributes, HTMLAnchorAttributes } from 'svelte/elements';
8
8
  import type { Snippet } from 'svelte';
9
- interface Props extends HTMLButtonAttributes {
9
+ type ButtonProps = HTMLButtonAttributes & {
10
+ href?: never;
11
+ };
12
+ type AnchorProps = HTMLAnchorAttributes & {
13
+ href: string;
14
+ };
15
+ type Props = (ButtonProps | AnchorProps) & {
10
16
  variant?: ButtonVariants['variant'];
11
17
  size?: ButtonVariants['size'];
12
18
  class?: string;
13
19
  children?: Snippet;
14
- }
20
+ };
15
21
  declare const Button: import("svelte").Component<Props, {}, "">;
16
22
  type Button = ReturnType<typeof Button>;
17
23
  export default Button;
package/dist/mdx.js CHANGED
@@ -379,7 +379,14 @@ function parseJsxExpression(expr) {
379
379
  return JSON.parse(trimmed);
380
380
  }
381
381
  catch {
382
- return trimmed;
382
+ // Convert JS object notation inside arrays to JSON
383
+ const jsonStr = trimmed.replace(/(\w+)\s*:/g, '"$1":').replace(/:\s*'([^']*)'/g, ': "$1"');
384
+ try {
385
+ return JSON.parse(jsonStr);
386
+ }
387
+ catch {
388
+ return trimmed;
389
+ }
383
390
  }
384
391
  }
385
392
  return trimmed;
@@ -449,6 +456,61 @@ function extractCodeBlockProps(node) {
449
456
  const filename = node.properties?.['data-filename'] || codeChild.properties?.['data-filename'];
450
457
  return { code, language, ...(filename ? { filename } : {}) };
451
458
  }
459
+ /**
460
+ * Detect GitHub-style alert blockquotes: > [!WARNING] content
461
+ * Returns the alert type and remaining content children, or null if not an alert.
462
+ */
463
+ const ALERT_TYPE_MAP = {
464
+ NOTE: 'note',
465
+ TIP: 'tip',
466
+ IMPORTANT: 'info',
467
+ WARNING: 'warning',
468
+ CAUTION: 'danger',
469
+ INFO: 'info',
470
+ SUCCESS: 'success',
471
+ ERROR: 'error',
472
+ DANGER: 'danger',
473
+ };
474
+ function extractBlockquoteAlert(node) {
475
+ if (node.type !== 'element' || node.tagName !== 'blockquote')
476
+ return null;
477
+ if (!node.children || node.children.length === 0)
478
+ return null;
479
+ // Find the first paragraph child
480
+ const firstP = node.children.find((c) => c.type === 'element' && c.tagName === 'p');
481
+ if (!firstP || !firstP.children || firstP.children.length === 0)
482
+ return null;
483
+ // Check first text node for [!TYPE] pattern
484
+ const firstText = firstP.children[0];
485
+ if (firstText.type !== 'text')
486
+ return null;
487
+ const match = firstText.value.match(/^\s*\[!(\w+)\]\s*\n?/);
488
+ if (!match)
489
+ return null;
490
+ const alertType = ALERT_TYPE_MAP[match[1].toUpperCase()];
491
+ if (!alertType)
492
+ return null;
493
+ // Build remaining content: modify the first paragraph to remove the alert marker
494
+ const remainingFirstPChildren = [...firstP.children];
495
+ const remainingText = firstText.value.slice(match[0].length);
496
+ if (remainingText.trim()) {
497
+ remainingFirstPChildren[0] = { ...firstText, value: remainingText };
498
+ }
499
+ else {
500
+ remainingFirstPChildren.shift();
501
+ }
502
+ const contentChildren = [];
503
+ if (remainingFirstPChildren.length > 0) {
504
+ contentChildren.push({ ...firstP, children: remainingFirstPChildren });
505
+ }
506
+ // Add any remaining blockquote children (paragraphs after the first)
507
+ for (const child of node.children) {
508
+ if (child !== firstP) {
509
+ contentChildren.push(child);
510
+ }
511
+ }
512
+ return { type: alertType, contentChildren };
513
+ }
452
514
  /**
453
515
  * Recursively extract text content from a hast node.
454
516
  */
@@ -720,6 +782,20 @@ async function hastChildrenToMdxNodes(children) {
720
782
  children: childNodes,
721
783
  });
722
784
  }
785
+ else if (extractBlockquoteAlert(child)) {
786
+ // GitHub-style alert blockquotes: > [!WARNING] content
787
+ flushHtmlBuffer();
788
+ const alert = extractBlockquoteAlert(child);
789
+ const contentNodes = alert.contentChildren.length > 0
790
+ ? await hastChildrenToMdxNodes(alert.contentChildren)
791
+ : [];
792
+ nodes.push({
793
+ type: 'component',
794
+ name: 'Callout',
795
+ props: { type: alert.type },
796
+ children: contentNodes,
797
+ });
798
+ }
723
799
  else {
724
800
  // Check if this regular element contains any component elements nested within
725
801
  if (hasNestedComponent(child)) {
@@ -116,8 +116,13 @@ export class OpenApiParser {
116
116
  parseParameters(parameters, spec) {
117
117
  const result = { path: [], query: [], header: [] };
118
118
  for (const param of parameters) {
119
+ if (!param)
120
+ continue;
119
121
  // Resolve $ref if present
120
122
  const resolved = param.$ref ? this.resolveRef(param.$ref, spec) : param;
123
+ // Skip params that failed to resolve or have no name
124
+ if (!resolved || !resolved.name || !resolved.in)
125
+ continue;
121
126
  const apiParam = {
122
127
  name: resolved.name,
123
128
  type: resolved.schema?.type || resolved.type || "string",
@@ -202,7 +207,7 @@ export class OpenApiParser {
202
207
  for (const segment of path) {
203
208
  current = current[segment];
204
209
  if (!current)
205
- return {};
210
+ return null;
206
211
  }
207
212
  return current;
208
213
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specra",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
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",