@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.
|
|
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/
|
|
37
|
-
"@startsimpli/
|
|
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
|
-
/**
|
|
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
|
-
|
|
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=
|
|
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'
|