@startsimpli/ui 0.4.31 → 0.4.32

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.31",
3
+ "version": "0.4.32",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -33,8 +33,8 @@
33
33
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
34
34
  "react": "^18.0.0 || ^19.0.0",
35
35
  "react-dom": "^18.0.0 || ^19.0.0",
36
- "@startsimpli/api": "0.5.31",
37
- "@startsimpli/auth": "0.4.26"
36
+ "@startsimpli/auth": "0.4.26",
37
+ "@startsimpli/api": "0.5.31"
38
38
  },
39
39
  "peerDependenciesMeta": {
40
40
  "@startsimpli/api": {
@@ -20,7 +20,9 @@ export interface BlogPostProps {
20
20
  theme?: 'light' | 'dark'
21
21
  /** schema.org JSON-LD object(s) rendered into the page head. */
22
22
  structuredData?: object | object[]
23
- /** Optional sidebar (TOC, share widgets, CTAs). Rendered right of the content on lg+. */
23
+ /** Left-rail outline, e.g. `<TableOfContents content={post.content} />`. Sticky on lg+. */
24
+ toc?: ReactNode
25
+ /** Optional sidebar (share widgets, CTAs). Rendered right of the content on lg+. */
24
26
  sidebar?: ReactNode
25
27
  className?: string
26
28
  }
@@ -38,6 +40,7 @@ export function BlogPost({
38
40
  byline,
39
41
  theme = 'light',
40
42
  structuredData,
43
+ toc,
41
44
  sidebar,
42
45
  className,
43
46
  }: BlogPostProps) {
@@ -46,6 +49,15 @@ export function BlogPost({
46
49
  ? new Date(post.publishedAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
47
50
  : null
48
51
 
52
+ const mainColClass =
53
+ toc && sidebar
54
+ ? 'lg:col-span-6'
55
+ : toc
56
+ ? 'lg:col-span-9'
57
+ : sidebar
58
+ ? 'lg:col-span-8'
59
+ : 'max-w-3xl mx-auto w-full'
60
+
49
61
  return (
50
62
  <article className={cn('py-12', className)}>
51
63
  {structuredData && <JsonLd data={structuredData} />}
@@ -68,8 +80,13 @@ export function BlogPost({
68
80
  </ol>
69
81
  </nav>
70
82
 
71
- <div className={cn('grid grid-cols-1 gap-8', sidebar ? 'lg:grid-cols-12' : '')}>
72
- <div className={sidebar ? 'lg:col-span-8' : 'max-w-3xl mx-auto w-full'}>
83
+ <div className={cn('grid grid-cols-1 gap-8 lg:gap-10', toc || sidebar ? 'lg:grid-cols-12' : '')}>
84
+ {toc && (
85
+ <aside className="hidden lg:block lg:col-span-3 order-first">
86
+ <div className="sticky top-8">{toc}</div>
87
+ </aside>
88
+ )}
89
+ <div className={mainColClass}>
73
90
  <h1 className="font-heading text-4xl md:text-5xl font-bold text-foreground mb-6">{post.title}</h1>
74
91
 
75
92
  <div className="flex items-center gap-4 mb-8 pb-8 border-b border-border text-sm text-muted-foreground">
@@ -106,7 +123,7 @@ export function BlogPost({
106
123
  </div>
107
124
 
108
125
  {sidebar && (
109
- <aside className="lg:col-span-4">
126
+ <aside className={toc ? 'lg:col-span-3' : 'lg:col-span-4'}>
110
127
  <div className="sticky top-8 space-y-6">{sidebar}</div>
111
128
  </aside>
112
129
  )}
@@ -0,0 +1,96 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useMemo, useState } from 'react'
4
+ import { cn } from '../../utils/cn'
5
+
6
+ /** Must match MarkdownRenderer's heading id slugify exactly so anchors line up. */
7
+ function slugify(text: string): string {
8
+ return text
9
+ .toLowerCase()
10
+ .replace(/[^a-z0-9]+/g, '-')
11
+ .replace(/(^-|-$)/g, '')
12
+ }
13
+
14
+ export interface TocItem {
15
+ id: string
16
+ text: string
17
+ level: number
18
+ }
19
+
20
+ /** Parse markdown headings (#, ##, ###) into a flat outline, skipping fenced code. */
21
+ export function extractToc(content: string): TocItem[] {
22
+ const items: TocItem[] = []
23
+ let inCode = false
24
+ for (const raw of content.split('\n')) {
25
+ const line = raw.trimEnd()
26
+ if (/^\s*```/.test(line)) {
27
+ inCode = !inCode
28
+ continue
29
+ }
30
+ if (inCode) continue
31
+ const m = /^(#{1,3})\s+(.+?)\s*#*$/.exec(line)
32
+ if (!m) continue
33
+ const text = m[2].replace(/[*_`]/g, '').trim()
34
+ const id = slugify(text)
35
+ if (!id) continue
36
+ items.push({ id, text, level: m[1].length })
37
+ }
38
+ return items
39
+ }
40
+
41
+ export interface TableOfContentsProps {
42
+ content: string
43
+ title?: string
44
+ className?: string
45
+ }
46
+
47
+ /**
48
+ * Sticky left-rail outline for an article, with scroll-spy highlighting.
49
+ * Renders nothing for content with fewer than 2 headings.
50
+ */
51
+ export function TableOfContents({ content, title = 'On this page', className }: TableOfContentsProps) {
52
+ const items = useMemo(() => extractToc(content), [content])
53
+ const [activeId, setActiveId] = useState<string | null>(null)
54
+
55
+ useEffect(() => {
56
+ if (items.length < 2) return
57
+ const observer = new IntersectionObserver(
58
+ (entries) => {
59
+ for (const entry of entries) {
60
+ if (entry.isIntersecting) setActiveId(entry.target.id)
61
+ }
62
+ },
63
+ { rootMargin: '0px 0px -70% 0px', threshold: 0 },
64
+ )
65
+ items.forEach((it) => {
66
+ const el = document.getElementById(it.id)
67
+ if (el) observer.observe(el)
68
+ })
69
+ return () => observer.disconnect()
70
+ }, [items])
71
+
72
+ if (items.length < 2) return null
73
+
74
+ return (
75
+ <nav className={cn('text-sm', className)} aria-label="Table of contents">
76
+ <p className="font-heading text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">{title}</p>
77
+ <ul className="border-l border-border">
78
+ {items.map((it) => (
79
+ <li key={it.id} style={{ paddingLeft: `${(Math.min(it.level, 3) - 1) * 12}px` }}>
80
+ <a
81
+ href={`#${it.id}`}
82
+ className={cn(
83
+ 'block -ml-px border-l-2 pl-3 py-1 transition-colors',
84
+ activeId === it.id
85
+ ? 'border-primary text-primary font-medium'
86
+ : 'border-transparent text-muted-foreground hover:text-foreground',
87
+ )}
88
+ >
89
+ {it.text}
90
+ </a>
91
+ </li>
92
+ ))}
93
+ </ul>
94
+ </nav>
95
+ )
96
+ }
@@ -11,3 +11,4 @@ export { MarkdownRenderer, type MarkdownRendererProps } from './MarkdownRenderer
11
11
  export { ArticleCard, type ArticleCardProps } from './ArticleCard'
12
12
  export { BlogIndex, type BlogIndexProps } from './BlogIndex'
13
13
  export { BlogPost, type BlogPostProps } from './BlogPost'
14
+ export { TableOfContents, extractToc, type TableOfContentsProps, type TocItem } from './TableOfContents'