specra 0.2.51 → 0.2.55

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.
@@ -0,0 +1 @@
1
+ export function rehypeBasePath(options?: {}): (tree: any) => void;
@@ -19,6 +19,7 @@ import rehypeKatex from 'rehype-katex'
19
19
  import rehypeRaw from 'rehype-raw'
20
20
  import fs from 'fs'
21
21
  import path from 'path'
22
+ import { rehypeBasePath } from '../dist/rehype-base-path.js'
22
23
 
23
24
  /**
24
25
  * Get mdsvex preprocessor config with all Specra remark/rehype plugins
@@ -45,8 +46,59 @@ export function specraMdsvexConfig(options = {}) {
45
46
  }
46
47
 
47
48
  /**
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.
49
+ * Read the deployment basePath from specra.config.json if present.
50
+ * Falls back to the BASE_PATH environment variable, then empty string.
51
+ */
52
+ /**
53
+ * Normalize a base path: ensure leading slash, strip trailing slash.
54
+ * "task_flow" → "/task_flow", "/task_flow/" → "/task_flow", "" → ""
55
+ */
56
+ function normalizeBasePath(bp) {
57
+ if (!bp) return ''
58
+ let normalized = bp.startsWith('/') ? bp : `/${bp}`
59
+ return normalized.replace(/\/+$/, '')
60
+ }
61
+
62
+ function resolveBasePath(configPath = path.join(process.cwd(), 'specra.config.json')) {
63
+ // Environment variable takes priority
64
+ if (process.env.BASE_PATH) return normalizeBasePath(process.env.BASE_PATH)
65
+
66
+ try {
67
+ if (fs.existsSync(configPath)) {
68
+ const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'))
69
+ if (raw.deployment?.basePath) {
70
+ if (raw.deployment?.customDomain) return ''
71
+ return normalizeBasePath(raw.deployment.basePath)
72
+ }
73
+ }
74
+ } catch {
75
+ // Ignore parse errors
76
+ }
77
+ return ''
78
+ }
79
+
80
+ /**
81
+ * Scan the docs/ directory and return prerender entries for every
82
+ * version-root page across all products and the default (no-product)
83
+ * layout. SvelteKit's prerender crawler picks up child pages by
84
+ * following links, but the version-root pages themselves are leaves
85
+ * with no inbound link from a prerendered ancestor (the product
86
+ * dropdown switches between them client-side), so adapter-static
87
+ * needs them seeded explicitly. Without these entries, navigating
88
+ * from one product to another in a static build fetches a
89
+ * non-existent `__data.json` and falls back through to the SPA
90
+ * `index.html` — the client then JSON.parse's HTML and throws
91
+ * `Unexpected token '<', "<!doctype html>"`.
92
+ *
93
+ * Layouts we handle:
94
+ * docs/v1/... → emit `/docs/v1`
95
+ * docs/v2/... → emit `/docs/v2`
96
+ * docs/sdk/_product_.json → recurse: docs/sdk/v1 → emit `/docs/sdk/v1`
97
+ * docs/api/_product_.json → recurse: docs/api/v1 → emit `/docs/api/v1`
98
+ *
99
+ * `_product_.json` is the marker file Specra uses to identify a
100
+ * multi-product layout (the same marker the SDK's product loader
101
+ * reads).
50
102
  */
51
103
  function discoverVersionEntries(docsDir = path.join(process.cwd(), 'docs')) {
52
104
  const entries = ['/']
@@ -55,31 +107,68 @@ function discoverVersionEntries(docsDir = path.join(process.cwd(), 'docs')) {
55
107
 
56
108
  const items = fs.readdirSync(docsDir, { withFileTypes: true })
57
109
  for (const item of items) {
58
- if (item.isDirectory() && /^v\d/.test(item.name)) {
110
+ if (!item.isDirectory()) continue
111
+
112
+ // Default-product version: docs/v1 → /docs/v1
113
+ if (/^v\d/.test(item.name)) {
59
114
  entries.push(`/docs/${item.name}`)
115
+ continue
116
+ }
117
+
118
+ // Multi-product: directory has a _product_.json marker. Recurse
119
+ // into it and emit one entry per version subdirectory.
120
+ const productDir = path.join(docsDir, item.name)
121
+ const productMarker = path.join(productDir, '_product_.json')
122
+ if (!fs.existsSync(productMarker)) continue
123
+
124
+ try {
125
+ const versionItems = fs.readdirSync(productDir, { withFileTypes: true })
126
+ for (const v of versionItems) {
127
+ if (v.isDirectory() && /^v\d/.test(v.name)) {
128
+ entries.push(`/docs/${item.name}/${v.name}`)
129
+ }
130
+ }
131
+ } catch {
132
+ // Ignore product-dir read errors — leave that product unseeded.
60
133
  }
61
134
  }
62
135
  } catch {
63
- // Ignore errors — fall back to just '/'
136
+ // Ignore top-level errors — fall back to just '/'.
64
137
  }
65
138
  return entries
66
139
  }
67
140
 
68
141
  /**
69
- * Create a full SvelteKit config with Specra defaults
142
+ * Create a full SvelteKit config with Specra defaults.
143
+ * Automatically reads deployment.basePath from specra.config.json
144
+ * for GitHub Pages deployments.
70
145
  */
71
146
  export function specraConfig(options = {}) {
72
147
  const { vitePreprocess } = options.vitePreprocess || {}
73
148
  const userPrerender = options.kit?.prerender || {}
149
+ const basePath = options.kit?.paths?.base ?? resolveBasePath()
150
+
151
+ // Inject base path rehype plugin into mdsvex if basePath is set
152
+ const mdsvexOptions = options.mdsvex || {}
153
+ if (basePath) {
154
+ mdsvexOptions.rehypePlugins = [
155
+ ...(mdsvexOptions.rehypePlugins || []),
156
+ [rehypeBasePath, { basePath }],
157
+ ]
158
+ }
74
159
 
75
160
  return {
76
161
  extensions: ['.svelte', '.md', '.svx', '.mdx'],
77
162
  preprocess: [
78
163
  ...(vitePreprocess ? [vitePreprocess()] : []),
79
- mdsvex(specraMdsvexConfig(options.mdsvex || {}))
164
+ mdsvex(specraMdsvexConfig(mdsvexOptions))
80
165
  ],
81
166
  kit: {
82
167
  ...options.kit,
168
+ paths: {
169
+ ...options.kit?.paths,
170
+ base: basePath,
171
+ },
83
172
  prerender: {
84
173
  handleHttpError: 'warn',
85
174
  handleMissingId: 'warn',
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { ChevronRight } from 'lucide-svelte';
3
+ import { base } from '$app/paths';
3
4
  import { getConfigContext } from '../../stores/config.js';
4
5
 
5
6
  interface Props {
@@ -13,8 +14,8 @@
13
14
 
14
15
  let docsBase = $derived(
15
16
  product && product !== '_default_'
16
- ? `/docs/${product}/${version}`
17
- : `/docs/${version}`
17
+ ? `${base}/docs/${product}/${version}`
18
+ : `${base}/docs/${version}`
18
19
  );
19
20
 
20
21
  const configStore = getConfigContext();
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { FileText, ArrowRight } from 'lucide-svelte';
3
+ import { base } from '$app/paths';
3
4
  import type { SpecraConfig } from '../../config.types.js';
4
5
  import type { Snippet } from 'svelte';
5
6
 
@@ -42,8 +43,8 @@
42
43
  // Match direct children of this category path
43
44
  if (!docPath.startsWith(categoryPath + '/')) return false;
44
45
  const remaining = docPath.slice(categoryPath.length + 1);
45
- // Only direct children (no further slashes, or is an index)
46
- return !remaining.includes('/') && remaining !== 'index';
46
+ // Only direct children (no further slashes)
47
+ return !remaining.includes('/');
47
48
  })
48
49
  .sort((a, b) => {
49
50
  const posA = a.meta?.sidebar_position ?? 999;
@@ -54,8 +55,8 @@
54
55
 
55
56
  const baseUrl = $derived(
56
57
  product && product !== '_default_'
57
- ? `/docs/${product}`
58
- : '/docs'
58
+ ? `${base}/docs/${product}`
59
+ : `${base}/docs`
59
60
  );
60
61
 
61
62
  // Note: We always use '/docs' as the base for non-product routes.
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { ChevronLeft, ChevronRight } from 'lucide-svelte';
3
+ import { base } from '$app/paths';
3
4
 
4
5
  interface NavDoc {
5
6
  title: string;
@@ -17,8 +18,8 @@
17
18
 
18
19
  let docsBase = $derived(
19
20
  product && product !== '_default_'
20
- ? `/docs/${product}/${version}`
21
- : `/docs/${version}`
21
+ ? `${base}/docs/${product}/${version}`
22
+ : `${base}/docs/${version}`
22
23
  );
23
24
  </script>
24
25
 
@@ -1,7 +1,16 @@
1
1
  <script lang="ts">
2
2
  import type { SpecraConfig } from '../../config.types.js';
3
+ import { base } from '$app/paths';
3
4
  import Logo from './Logo.svelte';
4
5
 
6
+ /** Prefix internal links with base path */
7
+ function resolveHref(href: string): string {
8
+ if (href.startsWith('/') && !href.startsWith('//')) {
9
+ return `${base}${href}`;
10
+ }
11
+ return href;
12
+ }
13
+
5
14
  interface Props {
6
15
  config: SpecraConfig;
7
16
  }
@@ -25,7 +34,7 @@
25
34
  {#each column.items as item, itemIdx (itemIdx)}
26
35
  <li>
27
36
  <a
28
- href={item.href}
37
+ href={resolveHref(item.href)}
29
38
  class="text-sm text-muted-foreground hover:text-foreground transition-colors"
30
39
  >
31
40
  {item.label}
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { Search, Menu, Github, Twitter, MessageCircle } from 'lucide-svelte';
3
+ import { base } from '$app/paths';
3
4
  import { getConfigContext } from '../../stores/config.js';
4
5
  import { sidebarStore } from '../../stores/sidebar.js';
5
6
  import VersionSwitcher from './VersionSwitcher.svelte';
@@ -21,7 +22,8 @@
21
22
  hidden?: boolean;
22
23
  }
23
24
 
24
- interface ProductItem {
25
+ /** Flat shape (from page components) */
26
+ interface ProductItemFlat {
25
27
  slug: string;
26
28
  label: string;
27
29
  icon?: string;
@@ -30,13 +32,27 @@
30
32
  isDefault: boolean;
31
33
  }
32
34
 
35
+ /** Nested shape (from SDK getProducts()) */
36
+ interface ProductItemNested {
37
+ slug: string;
38
+ config: {
39
+ label: string;
40
+ icon?: string;
41
+ badge?: string;
42
+ activeVersion?: string;
43
+ };
44
+ isDefault: boolean;
45
+ }
46
+
47
+ type ProductInput = ProductItemFlat | ProductItemNested;
48
+
33
49
  interface Props {
34
50
  currentVersion: string;
35
51
  versions: string[];
36
52
  versionsMeta?: VersionMeta[];
37
53
  versionBanner?: BannerConfig;
38
54
  config?: SpecraConfig;
39
- products?: ProductItem[];
55
+ products?: ProductInput[];
40
56
  currentProduct?: string;
41
57
  subheader?: Snippet;
42
58
  }
@@ -95,7 +111,7 @@
95
111
  >
96
112
  <Menu class="h-5 w-5" />
97
113
  </button>
98
- <a href="/" class="flex items-center gap-2">
114
+ <a href="{base}/" class="flex items-center gap-2">
99
115
  {#if !config.site.hideLogo}
100
116
  {#if config.site.logo}
101
117
  <Logo logo={config.site.logo} alt={config.site.title} className="w-18 object-contain" />
@@ -7,7 +7,8 @@ interface VersionMeta {
7
7
  badge?: string;
8
8
  hidden?: boolean;
9
9
  }
10
- interface ProductItem {
10
+ /** Flat shape (from page components) */
11
+ interface ProductItemFlat {
11
12
  slug: string;
12
13
  label: string;
13
14
  icon?: string;
@@ -15,13 +16,25 @@ interface ProductItem {
15
16
  activeVersion?: string;
16
17
  isDefault: boolean;
17
18
  }
19
+ /** Nested shape (from SDK getProducts()) */
20
+ interface ProductItemNested {
21
+ slug: string;
22
+ config: {
23
+ label: string;
24
+ icon?: string;
25
+ badge?: string;
26
+ activeVersion?: string;
27
+ };
28
+ isDefault: boolean;
29
+ }
30
+ type ProductInput = ProductItemFlat | ProductItemNested;
18
31
  interface Props {
19
32
  currentVersion: string;
20
33
  versions: string[];
21
34
  versionsMeta?: VersionMeta[];
22
35
  versionBanner?: BannerConfig;
23
36
  config?: SpecraConfig;
24
- products?: ProductItem[];
37
+ products?: ProductInput[];
25
38
  currentProduct?: string;
26
39
  subheader?: Snippet;
27
40
  }
@@ -1,14 +1,7 @@
1
1
  <script lang="ts">
2
2
  import MdxContent from './MdxContent.svelte';
3
3
  import type { Component } from 'svelte';
4
-
5
- interface MdxNode {
6
- type: 'html' | 'component';
7
- content?: string;
8
- name?: string;
9
- props?: Record<string, any>;
10
- children?: MdxNode[];
11
- }
4
+ import type { MdxNode } from '../../mdx.js';
12
5
 
13
6
  interface Props {
14
7
  nodes: MdxNode[];
@@ -0,0 +1,10 @@
1
+ import MdxContent from './MdxContent.svelte';
2
+ import type { Component } from 'svelte';
3
+ import type { MdxNode } from '../../mdx.js';
4
+ interface Props {
5
+ nodes: MdxNode[];
6
+ components: Record<string, Component>;
7
+ }
8
+ declare const MdxContent: Component<Props, {}, "">;
9
+ type MdxContent = ReturnType<typeof MdxContent>;
10
+ export default MdxContent;
@@ -1,13 +1,22 @@
1
1
  <script lang="ts">
2
2
  import { FileQuestion, Home, ArrowLeft } from 'lucide-svelte';
3
+ import { base } from '$app/paths';
3
4
 
4
5
  interface Props {
5
6
  version?: string;
7
+ product?: string;
6
8
  }
7
9
 
8
- let { version = '' }: Props = $props();
10
+ let { version = '', product = '' }: Props = $props();
9
11
 
10
- const homeLink = $derived(version ? `/${version}` : '/');
12
+ /** URL prefix: /docs/{product}/{version} for named products, /docs/{version} for default */
13
+ const homeLink = $derived(
14
+ product && product !== '_default_' && version
15
+ ? `${base}/docs/${product}/${version}`
16
+ : version
17
+ ? `${base}/docs/${version}`
18
+ : `${base}/`
19
+ );
11
20
  </script>
12
21
 
13
22
  <div class="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
@@ -1,5 +1,6 @@
1
1
  interface Props {
2
2
  version?: string;
3
+ product?: string;
3
4
  }
4
5
  declare const NotFoundContent: import("svelte").Component<Props, {}, "">;
5
6
  type NotFoundContent = ReturnType<typeof NotFoundContent>;
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { ChevronDown, Check } from 'lucide-svelte';
3
3
  import { goto } from '$app/navigation';
4
+ import { base } from '$app/paths';
4
5
  import { browser } from '$app/environment';
5
6
  import Icon from './Icon.svelte';
6
7
 
@@ -107,9 +108,9 @@
107
108
 
108
109
  const version = product.activeVersion || 'v1.0.0';
109
110
  if (product.isDefault) {
110
- goto(`/docs/${version}`);
111
+ goto(`${base}/docs/${version}`);
111
112
  } else {
112
- goto(`/docs/${product.slug}/${version}`);
113
+ goto(`${base}/docs/${product.slug}/${version}`);
113
114
  }
114
115
  }
115
116
 
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { page } from '$app/stores';
3
+ import { base } from '$app/paths';
3
4
  import { ChevronRight, ChevronDown, Lock } from 'lucide-svelte';
4
5
  import type { SpecraConfig } from '../../config.types.js';
5
6
  import Icon from './Icon.svelte';
@@ -50,11 +51,11 @@
50
51
 
51
52
  let { docs = [], version, product, onLinkClick, config, activeTabGroup }: Props = $props();
52
53
 
53
- /** URL prefix: /docs/{product}/{version} for named products, /docs/{version} for default */
54
+ /** URL prefix: {base}/docs/{product}/{version} for named products, {base}/docs/{version} for default */
54
55
  let docsBase = $derived(
55
56
  product && product !== '_default_'
56
- ? `/docs/${product}/${version}`
57
- : `/docs/${version}`
57
+ ? `${base}/docs/${product}/${version}`
58
+ : `${base}/docs/${version}`
58
59
  );
59
60
 
60
61
  const STORAGE_KEY = 'specra-sidebar-collapsed';
@@ -134,9 +135,8 @@
134
135
  if (isIndexFile) {
135
136
  rootGroups[groupName].position = doc.sidebar_position ?? 999;
136
137
  rootGroups[groupName].icon = doc.categoryIcon;
137
- } else {
138
- rootGroups[groupName].items.push(doc);
139
138
  }
139
+ rootGroups[groupName].items.push(doc);
140
140
  return;
141
141
  }
142
142
 
@@ -179,17 +179,14 @@
179
179
  if (doc.categoryIcon) {
180
180
  currentLevel[folder].icon = doc.categoryIcon;
181
181
  }
182
- } else {
183
- currentLevel[folder].items.push(doc);
184
182
  }
183
+ currentLevel[folder].items.push(doc);
185
184
  }
186
185
 
187
186
  currentLevel = currentLevel[folder].children;
188
187
  }
189
188
  } else {
190
- if (!isIndexFile) {
191
- standalone.push(doc);
192
- }
189
+ standalone.push(doc);
193
190
  }
194
191
  });
195
192
 
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { ChevronDown } from 'lucide-svelte';
3
3
  import { goto } from '$app/navigation';
4
+ import { base } from '$app/paths';
4
5
  import type { TabGroup, SpecraConfig } from '../../config.types.js';
5
6
  import Icon from './Icon.svelte';
6
7
 
@@ -32,8 +33,8 @@
32
33
 
33
34
  let docsBase = $derived(
34
35
  product && product !== '_default_' && version
35
- ? `/docs/${product}/${version}`
36
- : version ? `/docs/${version}` : '/docs'
36
+ ? `${base}/docs/${product}/${version}`
37
+ : version ? `${base}/docs/${version}` : `${base}/docs`
37
38
  );
38
39
 
39
40
  let dropdownOpen = $state(false);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * MDX/mdsvex component map for Specra documentation.
3
+ *
4
+ * In mdsvex, custom components are imported directly in .svx files.
5
+ * This file exports the component map for programmatic use.
6
+ *
7
+ * Usage in .svx files:
8
+ * ```svelte
9
+ * <script>
10
+ * import { Callout, CodeBlock, Tabs, Tab } from 'specra/components'
11
+ * </script>
12
+ *
13
+ * <Callout type="info">This is a callout</Callout>
14
+ * ```
15
+ */
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, };
19
+ /**
20
+ * Component map for passing to layout components that render MDX content.
21
+ */
22
+ export declare const mdxComponents: Record<string, Component>;
@@ -44,22 +44,23 @@ export function validatePathWithinDirectory(filePath, allowedDir) {
44
44
  * These patterns can execute arbitrary code during SSR
45
45
  */
46
46
  const DANGEROUS_PATTERNS = [
47
- // JavaScript execution
48
- /eval\s*\(/gi,
49
- /Function\s*\(/gi,
50
- /import\s*\(/gi,
51
- /require\s*\(/gi,
47
+ // JavaScript execution — require expression context (after { ; = or line start)
48
+ // to avoid false positives on prose like "bulk import (CSV...)" or "fetch (data)"
49
+ /(?:^|[{;=,])\s*eval\s*\(/gim,
50
+ /(?:^|[{;=,])\s*Function\s*\(/gim,
51
+ /(?:^|[{;=,])\s*import\s*\(/gim,
52
+ /(?:^|[{;=,])\s*require\s*\(/gim,
52
53
  // File system access
53
54
  /fs\.[a-z]+/gi,
54
- /readFile/gi,
55
- /writeFile/gi,
55
+ /(?:^|[{;=,])\s*readFile/gim,
56
+ /(?:^|[{;=,])\s*writeFile/gim,
56
57
  /process\.env/gi,
57
- // Network requests during SSR (legitimate client-side usage should use components)
58
- /fetch\s*\(/gi,
58
+ // Network requests during SSR require expression context
59
+ /(?:^|[{;=,])\s*fetch\s*\(/gim,
59
60
  // Dangerous Node.js modules
60
61
  /child_process/gi,
61
- /exec\s*\(/gi,
62
- /spawn\s*\(/gi,
62
+ /(?:^|[{;=,])\s*exec\s*\(/gim,
63
+ /(?:^|[{;=,])\s*spawn\s*\(/gim,
63
64
  // Script tag injection
64
65
  /<script[>\s]/gi,
65
66
  /javascript:/gi,
package/dist/mdx.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import matter from "gray-matter";
4
+ import yaml from "js-yaml";
5
+ import { rehypeBasePath } from "./rehype-base-path.js";
4
6
  import { unified } from "unified";
5
7
  import remarkParse from "remark-parse";
6
8
  import remarkGfm from "remark-gfm";
@@ -394,15 +396,41 @@ function parseJsxExpression(expr) {
394
396
  /**
395
397
  * Process markdown content to HTML using remark/rehype pipeline.
396
398
  */
399
+ function normalizeBasePath(bp) {
400
+ if (!bp)
401
+ return '';
402
+ let normalized = bp.startsWith('/') ? bp : `/${bp}`;
403
+ return normalized.replace(/\/+$/, '');
404
+ }
405
+ function resolveDeploymentBasePath() {
406
+ if (process.env.BASE_PATH)
407
+ return normalizeBasePath(process.env.BASE_PATH);
408
+ try {
409
+ const configPath = path.join(process.cwd(), 'specra.config.json');
410
+ if (fs.existsSync(configPath)) {
411
+ const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
412
+ if (raw.deployment?.basePath && !raw.deployment?.customDomain) {
413
+ return normalizeBasePath(raw.deployment.basePath);
414
+ }
415
+ }
416
+ }
417
+ catch { /* ignore */ }
418
+ return '';
419
+ }
397
420
  async function processMarkdownToHtml(markdown) {
398
- const result = await unified()
421
+ const basePath = resolveDeploymentBasePath();
422
+ const processor = unified()
399
423
  .use(remarkParse)
400
424
  .use(remarkGfm)
401
425
  .use(remarkMath)
402
426
  .use(remarkRehype, { allowDangerousHtml: true })
403
427
  .use(rehypeRaw)
404
428
  .use(rehypeSlug)
405
- .use(rehypeKatex)
429
+ .use(rehypeKatex);
430
+ if (basePath) {
431
+ processor.use(rehypeBasePath, { basePath });
432
+ }
433
+ const result = await processor
406
434
  .use(rehypeStringify)
407
435
  .process(markdown);
408
436
  return String(result);
@@ -1045,6 +1073,7 @@ async function processMarkdownToMdxNodes(markdown) {
1045
1073
  const dedented = dedentComponentChildren(preprocessed);
1046
1074
  // Ensure component block integrity in the markdown
1047
1075
  const normalized = ensureComponentBlockIntegrity(dedented);
1076
+ const basePath = resolveDeploymentBasePath();
1048
1077
  const processor = unified()
1049
1078
  .use(remarkParse)
1050
1079
  .use(remarkGfm)
@@ -1053,6 +1082,9 @@ async function processMarkdownToMdxNodes(markdown) {
1053
1082
  .use(rehypeRaw)
1054
1083
  .use(rehypeSlug)
1055
1084
  .use(rehypeKatex);
1085
+ if (basePath) {
1086
+ processor.use(rehypeBasePath, { basePath });
1087
+ }
1056
1088
  const mdast = processor.parse(normalized);
1057
1089
  const hast = await processor.run(mdast);
1058
1090
  // The hast root has children - process them into MdxNodes
@@ -1135,7 +1167,14 @@ function readDocFromFile(filePath, originalSlug) {
1135
1167
  return null;
1136
1168
  }
1137
1169
  const fileContents = fs.readFileSync(filePath, "utf8");
1138
- const { data, content } = matter(fileContents);
1170
+ const { data, content } = matter(fileContents, {
1171
+ engines: {
1172
+ yaml: {
1173
+ parse: (str) => yaml.load(str),
1174
+ stringify: (obj) => yaml.dump(obj),
1175
+ },
1176
+ },
1177
+ });
1139
1178
  // Security: Validate MDX content for dangerous patterns
1140
1179
  const securityCheck = validateMDXSecurity(content, {
1141
1180
  strictMode: process.env.NODE_ENV === 'production',
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Rehype plugin that prefixes internal absolute links with a base path.
3
+ * Internal links start with "/" and don't start with "http" or "//".
4
+ *
5
+ * Used for GitHub Pages deployments where the site lives under a subpath.
6
+ *
7
+ * Manually walks the tree to avoid ESM/CJS issues with unist-util-visit
8
+ * when loaded from svelte-config.js at Node.js config time.
9
+ */
10
+ import type { Root } from 'hast';
11
+ interface Options {
12
+ basePath?: string;
13
+ }
14
+ export declare function rehypeBasePath(options?: Options): (tree: Root) => void;
15
+ export {};
@@ -0,0 +1,32 @@
1
+ function walkElements(nodes, fn) {
2
+ for (const node of nodes) {
3
+ if (node.type === 'element') {
4
+ fn(node);
5
+ if (node.children) {
6
+ walkElements(node.children, fn);
7
+ }
8
+ }
9
+ }
10
+ }
11
+ export function rehypeBasePath(options = {}) {
12
+ const { basePath = '' } = options;
13
+ if (!basePath)
14
+ return () => { };
15
+ const cleanBase = basePath.replace(/\/$/, '');
16
+ return (tree) => {
17
+ walkElements(tree.children, (node) => {
18
+ if (node.tagName === 'a' && node.properties?.href) {
19
+ const href = node.properties.href;
20
+ if (typeof href === 'string' && href.startsWith('/') && !href.startsWith('//') && !href.startsWith(cleanBase + '/')) {
21
+ node.properties.href = cleanBase + href;
22
+ }
23
+ }
24
+ if (node.tagName === 'img' && node.properties?.src) {
25
+ const src = node.properties.src;
26
+ if (typeof src === 'string' && src.startsWith('/') && !src.startsWith('//') && !src.startsWith(cleanBase + '/')) {
27
+ node.properties.src = cleanBase + src;
28
+ }
29
+ }
30
+ });
31
+ };
32
+ }
@@ -79,13 +79,10 @@ export function buildSidebarStructure(docs) {
79
79
  };
80
80
  }
81
81
  if (isIndexFile) {
82
- // Use categoryPosition if available (from _category_.json), otherwise sidebar_position from frontmatter
83
82
  rootGroups[groupName].position = doc.categoryPosition ?? doc.meta.sidebar_position ?? 999;
84
83
  rootGroups[groupName].icon = doc.categoryIcon;
85
84
  }
86
- else {
87
- rootGroups[groupName].items.push(doc);
88
- }
85
+ rootGroups[groupName].items.push(doc);
89
86
  return;
90
87
  }
91
88
  if (pathParts.length > 1) {
@@ -127,17 +124,13 @@ export function buildSidebarStructure(docs) {
127
124
  currentLevel[folder].defaultCollapsed = doc.categoryCollapsed;
128
125
  }
129
126
  }
130
- else {
131
- currentLevel[folder].items.push(doc);
132
- }
127
+ currentLevel[folder].items.push(doc);
133
128
  }
134
129
  currentLevel = currentLevel[folder].children;
135
130
  }
136
131
  }
137
132
  else {
138
- if (!isIndexFile) {
139
- standalone.push(doc);
140
- }
133
+ standalone.push(doc);
141
134
  }
142
135
  });
143
136
  return { rootGroups, standalone };
@@ -159,28 +159,39 @@
159
159
  text-decoration: none;
160
160
  }
161
161
 
162
- /* Link styling - primary color, no underline until hover */
162
+ /* Prose text links - underlined for readability */
163
163
  main .prose a {
164
164
  color: var(--color-primary);
165
- text-decoration: none;
166
- transition: color 0.2s ease, text-decoration 0.2s ease;
165
+ text-decoration: underline;
166
+ text-underline-offset: 3px;
167
+ text-decoration-thickness: 1px;
168
+ text-decoration-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
169
+ transition: text-decoration-color 0.2s ease;
167
170
  }
168
171
 
169
172
  main .prose a:hover {
170
- text-decoration: none !important;
171
- color: var(--color-primary);
173
+ text-decoration-color: var(--color-primary);
172
174
  }
173
175
 
174
- /* Prose links in documentation */
175
176
  .prose a {
176
177
  color: var(--color-primary);
177
- text-decoration: none !important;
178
+ text-decoration: underline;
179
+ text-underline-offset: 3px;
180
+ text-decoration-thickness: 1px;
181
+ text-decoration-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
178
182
  font-weight: 500;
179
183
  }
180
184
 
181
185
  .prose a:hover {
186
+ text-decoration-color: var(--color-primary);
187
+ }
188
+
189
+ /* Navigation/UI links inside prose - no underline */
190
+ .prose .not-prose a,
191
+ .prose nav a,
192
+ .prose [data-doc-nav] a,
193
+ .prose .doc-navigation a {
182
194
  text-decoration: none !important;
183
- color: var(--color-primary);
184
195
  }
185
196
 
186
197
  /* Sidebar links - no underline on hover */
package/dist/utils.d.ts CHANGED
@@ -1,13 +1,11 @@
1
1
  import { type ClassValue } from 'clsx';
2
2
  export declare function cn(...inputs: ClassValue[]): string;
3
3
  /**
4
- * Get the correct asset path based on deployment configuration
5
- * Handles different deployment scenarios:
6
- * - Vercel/Node.js hosting (standalone build): No basePath needed
7
- * - GitHub Pages without custom domain: Uses basePath from config
8
- * - Static hosting with custom domain: No basePath needed
4
+ * Get the correct asset path based on deployment configuration.
5
+ * Uses SvelteKit's base path (from kit.paths.base) which is resolved
6
+ * from deployment.basePath in specra.config.json or the BASE_PATH env var.
9
7
  *
10
- * @param path - The asset path (can start with or without '/')
11
- * @returns The properly formatted asset path
8
+ * @param assetPath - The asset path (can start with or without '/')
9
+ * @returns The properly formatted asset path with base prefix
12
10
  */
13
- export declare function getAssetPath(path: string): string;
11
+ export declare function getAssetPath(assetPath: string): string;
package/dist/utils.js CHANGED
@@ -4,27 +4,20 @@ export function cn(...inputs) {
4
4
  return twMerge(clsx(inputs));
5
5
  }
6
6
  /**
7
- * Get the correct asset path based on deployment configuration
8
- * Handles different deployment scenarios:
9
- * - Vercel/Node.js hosting (standalone build): No basePath needed
10
- * - GitHub Pages without custom domain: Uses basePath from config
11
- * - Static hosting with custom domain: No basePath needed
7
+ * Get the correct asset path based on deployment configuration.
8
+ * Uses SvelteKit's base path (from kit.paths.base) which is resolved
9
+ * from deployment.basePath in specra.config.json or the BASE_PATH env var.
12
10
  *
13
- * @param path - The asset path (can start with or without '/')
14
- * @returns The properly formatted asset path
11
+ * @param assetPath - The asset path (can start with or without '/')
12
+ * @returns The properly formatted asset path with base prefix
15
13
  */
16
- export function getAssetPath(path) {
17
- // Get basePath from Next.js config (set during build for static exports)
18
- const basePath = process.env.NEXT_PUBLIC_BASE_PATH || process.env.__NEXT_ROUTER_BASEPATH || '';
19
- // Normalize the input path: ensure it starts with '/'
20
- const normalizedPath = path.startsWith('/') ? path : `/${path}`;
21
- // If we have a basePath (GitHub Pages without custom domain), prepend it
14
+ export function getAssetPath(assetPath) {
15
+ const basePath = process.env.BASE_PATH || '';
16
+ const normalizedPath = assetPath.startsWith('/') ? assetPath : `/${assetPath}`;
22
17
  if (basePath) {
23
- // Normalize basePath: remove trailing slash, ensure leading slash
24
18
  const normalizedBase = basePath.startsWith('/') ? basePath : `/${basePath}`;
25
19
  const cleanBase = normalizedBase.replace(/\/$/, '');
26
20
  return `${cleanBase}${normalizedPath}`;
27
21
  }
28
- // Default: return the normalized path (works for Vercel, custom domains, and dev)
29
22
  return normalizedPath;
30
23
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specra",
3
- "version": "0.2.51",
3
+ "version": "0.2.55",
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",
@@ -78,13 +78,14 @@
78
78
  "date-fns": "^4.1.0",
79
79
  "embla-carousel-svelte": "^8.5.1",
80
80
  "gray-matter": "^4.0.3",
81
+ "hast-util-to-html": "^9.0.0",
82
+ "js-yaml": "^4.1.1",
81
83
  "katex": "^0.16.27",
82
84
  "lucide-svelte": "^0.454.0",
83
85
  "mdsvex": "^0.12.0",
84
86
  "meilisearch": "^0.54.0",
85
87
  "mermaid": "^11.12.2",
86
88
  "mode-watcher": "^0.5.0",
87
- "hast-util-to-html": "^9.0.0",
88
89
  "rehype-katex": "^7.0.1",
89
90
  "rehype-raw": "^7.0.0",
90
91
  "rehype-slug": "^6.0.0",
@@ -103,6 +104,7 @@
103
104
  "@sveltejs/kit": "^2.0.0",
104
105
  "@sveltejs/package": "^2.0.0",
105
106
  "@sveltejs/vite-plugin-svelte": "^6.0.0",
107
+ "@types/js-yaml": "^4.0.9",
106
108
  "@types/node": "^22",
107
109
  "svelte": "^5.0.0",
108
110
  "svelte-check": "^4.0.0",