livemark 0.0.0-dev → 0.7.0
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/README.md +5 -1
- package/build/actions/changelog/build.d.ts +14 -0
- package/build/actions/changelog/build.js +158 -0
- package/build/actions/changelog/build.unit.d.ts +1 -0
- package/build/actions/changelog/build.unit.js +66 -0
- package/build/actions/config/define.unit.js +11 -16
- package/build/actions/config/load.js +6 -3
- package/build/actions/config/load.unit.js +3 -3
- package/build/commands/build.js +4 -3
- package/build/commands/escape.d.ts +9 -0
- package/build/commands/escape.js +56 -0
- package/build/docs/markdown/includes/example.d.ts +53 -4
- package/build/docs/markdown/includes/example.js +2 -2
- package/build/livemark.config.d.ts +53 -4
- package/build/livemark.config.js +22 -2
- package/build/models/config.d.ts +315 -8
- package/build/models/config.js +25 -5
- package/build/models/link.d.ts +13 -0
- package/build/models/link.js +9 -0
- package/build/models/patch.d.ts +21 -0
- package/build/models/patch.js +7 -0
- package/build/models/section.d.ts +75 -0
- package/build/models/section.js +26 -0
- package/build/models/theme.d.ts +72 -0
- package/build/models/theme.js +73 -0
- package/build/program.js +4 -2
- package/build/vite.config.d.ts +1 -1
- package/build/website/assets/logo.svg +117 -0
- package/build/website/components/Article.tsx +172 -0
- package/build/website/components/BackToTop.tsx +32 -0
- package/build/website/components/Banner.tsx +2 -2
- package/build/website/components/BlogIndex.tsx +121 -0
- package/build/website/components/BlogSidebar.tsx +162 -0
- package/build/website/components/Breadcrumbs.tsx +5 -2
- package/build/website/components/Card.tsx +48 -0
- package/build/website/components/Cards.tsx +0 -58
- package/build/website/components/ChangelogIndex.tsx +111 -0
- package/build/website/components/ChangelogSidebar.tsx +120 -0
- package/build/website/components/CodeBlock.tsx +57 -40
- package/build/website/components/Column.tsx +4 -0
- package/build/website/components/Columns.tsx +0 -5
- package/build/website/components/Footer.tsx +12 -5
- package/build/website/components/InlineIcon.tsx +2 -13
- package/build/website/components/Layout.tsx +155 -185
- package/build/website/components/Mermaid.tsx +14 -33
- package/build/website/components/MermaidRenderer.tsx +37 -0
- package/build/website/components/MiniSidebar.tsx +49 -0
- package/build/website/components/PageToolbar.tsx +21 -11
- package/build/website/components/PrevNext.tsx +20 -12
- package/build/website/components/Search.tsx +28 -0
- package/build/website/components/SearchDialog.tsx +10 -10
- package/build/website/components/Sidebar.tsx +160 -0
- package/build/website/components/SidebarLinks.tsx +70 -0
- package/build/website/components/SidebarSections.tsx +71 -0
- package/build/website/components/SiteTitle.tsx +13 -8
- package/build/website/components/TagIndex.tsx +120 -0
- package/build/website/components/Theme.tsx +0 -1
- package/build/website/components/Toc.tsx +128 -1
- package/build/website/components/ZoomImage.tsx +15 -20
- package/build/website/content/article.ts +78 -0
- package/build/website/content/tag.ts +29 -0
- package/build/website/content-collections.ts +53 -26
- package/build/website/elements/sidebar.tsx +4 -4
- package/build/website/env.d.ts +4 -0
- package/build/website/helpers/article-icon.ts +2 -0
- package/build/website/helpers/article.ts +90 -0
- package/build/website/helpers/changelog-source.ts +16 -0
- package/build/website/helpers/changelog-source.unit.ts +38 -0
- package/build/website/helpers/dynamic-icon-resolver.tsx +19 -0
- package/build/website/helpers/dynamic-icon.tsx +12 -0
- package/build/website/helpers/favicon.ts +7 -0
- package/build/website/helpers/model.ts +4 -0
- package/build/website/helpers/resolve-asset-path.ts +9 -0
- package/build/website/helpers/section.ts +50 -0
- package/build/website/helpers/section.unit.ts +71 -0
- package/build/website/helpers/toc-context.ts +1 -1
- package/build/website/helpers/toc.ts +1 -6
- package/build/website/models/article.ts +48 -33
- package/build/website/models/toc.ts +5 -0
- package/build/website/plugins/rehype-task-list-a11y.ts +33 -0
- package/build/website/plugins/remark-ansi.ts +1 -0
- package/build/website/plugins/remark-image.ts +22 -13
- package/build/website/plugins/vite-livemark.ts +91 -0
- package/build/website/routeTree.gen.ts +9 -9
- package/build/website/routes/$.tsx +74 -131
- package/build/website/routes/__root.tsx +36 -23
- package/build/website/styles/general.css +26 -210
- package/build/website/styles/markdown.css +214 -0
- package/build/website/vite.config.ts +38 -3
- package/package.json +25 -16
- package/build/website/globals.d.ts +0 -8
- package/build/website/helpers/articles.ts +0 -24
- package/build/website/public/logo.svg +0 -72
- package/build/website/routes/index.tsx +0 -13
|
@@ -2,7 +2,7 @@ import { useState, useRef, useCallback, useMemo } from "react"
|
|
|
2
2
|
import { useNavigate } from "@tanstack/react-router"
|
|
3
3
|
import { FileText } from "lucide-react"
|
|
4
4
|
import { create, insertMultiple, search } from "@orama/orama"
|
|
5
|
-
import { sortedArticles } from "../
|
|
5
|
+
import { sortedArticles } from "../content/article.ts"
|
|
6
6
|
import { articleIcons } from "../helpers/article-icon.ts"
|
|
7
7
|
import {
|
|
8
8
|
CommandDialog,
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
|
|
16
16
|
interface SearchResult {
|
|
17
17
|
title: string
|
|
18
|
-
|
|
18
|
+
path: string
|
|
19
19
|
icon: string
|
|
20
20
|
snippet: string
|
|
21
21
|
group: string
|
|
@@ -55,12 +55,12 @@ export function SearchDialog(props: {
|
|
|
55
55
|
const toResult = useCallback(
|
|
56
56
|
(article: (typeof sortedArticles)[number], term: string): SearchResult => ({
|
|
57
57
|
title: article.title,
|
|
58
|
-
|
|
58
|
+
path: article.path,
|
|
59
59
|
icon: article.icon,
|
|
60
60
|
snippet: term
|
|
61
61
|
? extractSnippet(article.searchText, term)
|
|
62
62
|
: (article.description ?? ""),
|
|
63
|
-
group: groupFor(article.
|
|
63
|
+
group: groupFor(article.path),
|
|
64
64
|
}),
|
|
65
65
|
[],
|
|
66
66
|
)
|
|
@@ -89,7 +89,7 @@ export function SearchDialog(props: {
|
|
|
89
89
|
{
|
|
90
90
|
...sortedArticles[0],
|
|
91
91
|
title: hit.document.title,
|
|
92
|
-
|
|
92
|
+
path: "",
|
|
93
93
|
searchText: hit.document.description,
|
|
94
94
|
description: hit.document.description,
|
|
95
95
|
icon: "",
|
|
@@ -104,13 +104,13 @@ export function SearchDialog(props: {
|
|
|
104
104
|
)
|
|
105
105
|
|
|
106
106
|
const handleSelect = useCallback(
|
|
107
|
-
(
|
|
107
|
+
(path: string) => {
|
|
108
108
|
props.onOpenChange(false)
|
|
109
109
|
setQuery("")
|
|
110
110
|
setResults([])
|
|
111
111
|
navigate({
|
|
112
112
|
to: "/$",
|
|
113
|
-
params: { _splat:
|
|
113
|
+
params: { _splat: path.replace(/^\/|\/$/g, "") },
|
|
114
114
|
})
|
|
115
115
|
},
|
|
116
116
|
[navigate, props.onOpenChange],
|
|
@@ -153,9 +153,9 @@ export function SearchDialog(props: {
|
|
|
153
153
|
const Icon = articleIcons[result.icon] ?? FileText
|
|
154
154
|
return (
|
|
155
155
|
<CommandItem
|
|
156
|
-
key={result.
|
|
157
|
-
value={result.
|
|
158
|
-
onSelect={() => handleSelect(result.
|
|
156
|
+
key={result.path}
|
|
157
|
+
value={result.path}
|
|
158
|
+
onSelect={() => handleSelect(result.path)}
|
|
159
159
|
className="py-2.5"
|
|
160
160
|
>
|
|
161
161
|
<Icon className="size-5 shrink-0 mt-0.5 self-start opacity-60" />
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Link, useLocation } from "@tanstack/react-router"
|
|
2
|
+
import { useHotkey } from "@tanstack/react-hotkeys"
|
|
3
|
+
import { ChevronRight, FileText } from "lucide-react"
|
|
4
|
+
import {
|
|
5
|
+
articleGroups,
|
|
6
|
+
currentSection,
|
|
7
|
+
sectionArticleGroups,
|
|
8
|
+
} from "../content/article.ts"
|
|
9
|
+
import { articleIcons } from "../helpers/article-icon.ts"
|
|
10
|
+
import type { ArticleNode } from "../models/article.ts"
|
|
11
|
+
import {
|
|
12
|
+
Collapsible,
|
|
13
|
+
CollapsibleContent,
|
|
14
|
+
CollapsibleTrigger,
|
|
15
|
+
} from "../elements/collapsible.tsx"
|
|
16
|
+
import {
|
|
17
|
+
Sidebar as SidebarRoot,
|
|
18
|
+
SidebarContent,
|
|
19
|
+
SidebarFooter,
|
|
20
|
+
SidebarGroup,
|
|
21
|
+
SidebarGroupContent,
|
|
22
|
+
SidebarGroupLabel,
|
|
23
|
+
SidebarHeader,
|
|
24
|
+
SidebarMenu,
|
|
25
|
+
SidebarMenuButton,
|
|
26
|
+
SidebarMenuItem,
|
|
27
|
+
SidebarMenuSub,
|
|
28
|
+
SidebarMenuSubButton,
|
|
29
|
+
SidebarMenuSubItem,
|
|
30
|
+
SidebarRail,
|
|
31
|
+
useSidebar,
|
|
32
|
+
} from "../elements/sidebar.tsx"
|
|
33
|
+
import { Search } from "./Search.tsx"
|
|
34
|
+
import { SidebarLinks } from "./SidebarLinks.tsx"
|
|
35
|
+
import { SidebarSections } from "./SidebarSections.tsx"
|
|
36
|
+
import { SiteTitle } from "./SiteTitle.tsx"
|
|
37
|
+
import { Theme } from "./Theme.tsx"
|
|
38
|
+
|
|
39
|
+
/** Application sidebar with article navigation, search, and theme toggle */
|
|
40
|
+
export function Sidebar() {
|
|
41
|
+
const pathname = useLocation({ select: l => l.pathname })
|
|
42
|
+
const { toggleSidebar } = useSidebar()
|
|
43
|
+
|
|
44
|
+
useHotkey("S", toggleSidebar)
|
|
45
|
+
|
|
46
|
+
const configSections = import.meta.env.CONFIG.sections
|
|
47
|
+
const section = currentSection(`/${pathname.replace(/^\/|\/$/g, "")}/`)
|
|
48
|
+
const groups = configSections?.length
|
|
49
|
+
? (sectionArticleGroups.get(section?.prefix ?? "__default__") ??
|
|
50
|
+
articleGroups)
|
|
51
|
+
: articleGroups
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<SidebarRoot>
|
|
55
|
+
<SidebarHeader>
|
|
56
|
+
<SidebarMenu>
|
|
57
|
+
<SidebarMenuItem>
|
|
58
|
+
<SidebarMenuButton size="lg" render={<Link to="/" />}>
|
|
59
|
+
<SiteTitle />
|
|
60
|
+
</SidebarMenuButton>
|
|
61
|
+
</SidebarMenuItem>
|
|
62
|
+
</SidebarMenu>
|
|
63
|
+
</SidebarHeader>
|
|
64
|
+
<SidebarContent>
|
|
65
|
+
<SidebarSections />
|
|
66
|
+
{groups.map((group, index) => (
|
|
67
|
+
<SidebarGroup key={group.name ?? `__unnamed-${index}`}>
|
|
68
|
+
{group.name && (
|
|
69
|
+
<SidebarGroupLabel className="uppercase font-mono text-xs tracking-widest">
|
|
70
|
+
{group.name}
|
|
71
|
+
</SidebarGroupLabel>
|
|
72
|
+
)}
|
|
73
|
+
<SidebarGroupContent>
|
|
74
|
+
<SidebarMenu>
|
|
75
|
+
{group.nodes.map(node => (
|
|
76
|
+
<NavNode key={node.path} node={node} currentPath={pathname} />
|
|
77
|
+
))}
|
|
78
|
+
</SidebarMenu>
|
|
79
|
+
</SidebarGroupContent>
|
|
80
|
+
</SidebarGroup>
|
|
81
|
+
))}
|
|
82
|
+
<SidebarLinks />
|
|
83
|
+
</SidebarContent>
|
|
84
|
+
<SidebarFooter className="p-4 space-y-2">
|
|
85
|
+
<Search />
|
|
86
|
+
<Theme />
|
|
87
|
+
</SidebarFooter>
|
|
88
|
+
<SidebarRail />
|
|
89
|
+
</SidebarRoot>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function splatFor(pathname: string) {
|
|
94
|
+
return { _splat: pathname.replace(/^\/|\/$/g, "") }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isActive(articlePathname: string, currentPath: string) {
|
|
98
|
+
const normalized = `/${currentPath.replace(/^\/|\/$/g, "")}/`
|
|
99
|
+
return articlePathname === normalized
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function NavNode(props: { node: ArticleNode; currentPath: string }) {
|
|
103
|
+
const { node, currentPath } = props
|
|
104
|
+
const Icon = articleIcons[node.icon] ?? FileText
|
|
105
|
+
const active = isActive(node.path, currentPath)
|
|
106
|
+
|
|
107
|
+
if (node.children.length === 0) {
|
|
108
|
+
return (
|
|
109
|
+
<SidebarMenuItem>
|
|
110
|
+
<SidebarMenuButton
|
|
111
|
+
isActive={active}
|
|
112
|
+
className={active ? "" : "opacity-75"}
|
|
113
|
+
render={<Link to="/$" params={splatFor(node.path)} />}
|
|
114
|
+
>
|
|
115
|
+
<Icon className="size-4" />
|
|
116
|
+
<span>{node.label ?? node.title}</span>
|
|
117
|
+
</SidebarMenuButton>
|
|
118
|
+
</SidebarMenuItem>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<SidebarMenuItem>
|
|
124
|
+
<Collapsible defaultOpen className="group/collapsible">
|
|
125
|
+
<SidebarMenuButton
|
|
126
|
+
isActive={active}
|
|
127
|
+
className={active ? "" : "opacity-75"}
|
|
128
|
+
render={<Link to="/$" params={splatFor(node.path)} />}
|
|
129
|
+
>
|
|
130
|
+
<Icon className="size-4" />
|
|
131
|
+
<span>{node.label ?? node.title}</span>
|
|
132
|
+
</SidebarMenuButton>
|
|
133
|
+
<CollapsibleTrigger
|
|
134
|
+
aria-label={`Toggle ${node.label ?? node.title} submenu`}
|
|
135
|
+
className="absolute right-1 top-1.5 p-1 rounded-md hover:bg-sidebar-accent"
|
|
136
|
+
>
|
|
137
|
+
<ChevronRight className="size-3.5 transition-transform group-data-[open]/collapsible:rotate-90" />
|
|
138
|
+
</CollapsibleTrigger>
|
|
139
|
+
<CollapsibleContent>
|
|
140
|
+
<SidebarMenuSub>
|
|
141
|
+
{node.children.map(child => {
|
|
142
|
+
const childIsActive = isActive(child.path, currentPath)
|
|
143
|
+
return (
|
|
144
|
+
<SidebarMenuSubItem key={child.path}>
|
|
145
|
+
<SidebarMenuSubButton
|
|
146
|
+
isActive={childIsActive}
|
|
147
|
+
className={childIsActive ? "" : "opacity-75"}
|
|
148
|
+
render={<Link to="/$" params={splatFor(child.path)} />}
|
|
149
|
+
>
|
|
150
|
+
<span>{child.label ?? child.title}</span>
|
|
151
|
+
</SidebarMenuSubButton>
|
|
152
|
+
</SidebarMenuSubItem>
|
|
153
|
+
)
|
|
154
|
+
})}
|
|
155
|
+
</SidebarMenuSub>
|
|
156
|
+
</CollapsibleContent>
|
|
157
|
+
</Collapsible>
|
|
158
|
+
</SidebarMenuItem>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useLocation } from "@tanstack/react-router"
|
|
2
|
+
import { ExternalLink } from "lucide-react"
|
|
3
|
+
import { currentSection } from "../content/article.ts"
|
|
4
|
+
import { DynamicIcon } from "../helpers/dynamic-icon.tsx"
|
|
5
|
+
import {
|
|
6
|
+
SidebarGroup,
|
|
7
|
+
SidebarGroupContent,
|
|
8
|
+
SidebarGroupLabel,
|
|
9
|
+
SidebarMenu,
|
|
10
|
+
SidebarMenuButton,
|
|
11
|
+
SidebarMenuItem,
|
|
12
|
+
} from "../elements/sidebar.tsx"
|
|
13
|
+
|
|
14
|
+
/** Shared Links group. Renders sidebar-positioned links always, and
|
|
15
|
+
* header-positioned links only on mobile (mobile collapses all chrome
|
|
16
|
+
* links into the sidebar since the header strip is hidden below `md`). */
|
|
17
|
+
export function SidebarLinks() {
|
|
18
|
+
const pathname = useLocation({ select: l => l.pathname })
|
|
19
|
+
const section = currentSection(`/${pathname.replace(/^\/|\/$/g, "")}/`)
|
|
20
|
+
const links =
|
|
21
|
+
import.meta.env.CONFIG.links?.filter(
|
|
22
|
+
link => !link.prefix || link.prefix === section?.prefix,
|
|
23
|
+
) ?? []
|
|
24
|
+
|
|
25
|
+
if (!links.length) return null
|
|
26
|
+
const hasDesktopItems = links.some(l => l.position === "sidebar")
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<SidebarGroup className={hasDesktopItems ? undefined : "md:hidden"}>
|
|
30
|
+
<SidebarGroupLabel className="uppercase font-mono text-xs tracking-widest">
|
|
31
|
+
Links
|
|
32
|
+
</SidebarGroupLabel>
|
|
33
|
+
<SidebarGroupContent>
|
|
34
|
+
<SidebarMenu>
|
|
35
|
+
{links.map(link => {
|
|
36
|
+
const mobileOnly = link.position !== "sidebar"
|
|
37
|
+
return (
|
|
38
|
+
<SidebarMenuItem
|
|
39
|
+
key={link.url}
|
|
40
|
+
className={mobileOnly ? "md:hidden" : undefined}
|
|
41
|
+
>
|
|
42
|
+
<SidebarMenuButton
|
|
43
|
+
className="text-muted-foreground"
|
|
44
|
+
render={
|
|
45
|
+
<a
|
|
46
|
+
href={link.url}
|
|
47
|
+
target="_blank"
|
|
48
|
+
rel="noopener noreferrer"
|
|
49
|
+
/>
|
|
50
|
+
}
|
|
51
|
+
>
|
|
52
|
+
{link.icon && (
|
|
53
|
+
<DynamicIcon name={link.icon} className="size-4" />
|
|
54
|
+
)}
|
|
55
|
+
<span>
|
|
56
|
+
{link.title}{" "}
|
|
57
|
+
<ExternalLink
|
|
58
|
+
className="inline -mt-0.5 opacity-75"
|
|
59
|
+
style={{ width: 12, height: 12 }}
|
|
60
|
+
/>
|
|
61
|
+
</span>
|
|
62
|
+
</SidebarMenuButton>
|
|
63
|
+
</SidebarMenuItem>
|
|
64
|
+
)
|
|
65
|
+
})}
|
|
66
|
+
</SidebarMenu>
|
|
67
|
+
</SidebarGroupContent>
|
|
68
|
+
</SidebarGroup>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Link, useLocation, useMatch } from "@tanstack/react-router"
|
|
2
|
+
import { currentSection, sectionFirstArticle } from "../content/article.ts"
|
|
3
|
+
import { DynamicIcon } from "../helpers/dynamic-icon.tsx"
|
|
4
|
+
import { sectionIcon } from "../helpers/section.ts"
|
|
5
|
+
import {
|
|
6
|
+
SidebarGroup,
|
|
7
|
+
SidebarGroupContent,
|
|
8
|
+
SidebarGroupLabel,
|
|
9
|
+
SidebarMenu,
|
|
10
|
+
SidebarMenuButton,
|
|
11
|
+
SidebarMenuItem,
|
|
12
|
+
} from "../elements/sidebar.tsx"
|
|
13
|
+
|
|
14
|
+
function splatFor(pathname: string) {
|
|
15
|
+
return { _splat: pathname.replace(/^\/|\/$/g, "") }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Shared Sections group. Renders sidebar-positioned sections always, and
|
|
19
|
+
* header-positioned sections only on mobile (mobile collapses all sections
|
|
20
|
+
* into the sidebar since the header nav strip is hidden below `md`). */
|
|
21
|
+
export function SidebarSections() {
|
|
22
|
+
const pathname = useLocation({ select: l => l.pathname })
|
|
23
|
+
const articleRoute = useMatch({ from: "/$", shouldThrow: false })
|
|
24
|
+
const section = articleRoute
|
|
25
|
+
? currentSection(`/${pathname.replace(/^\/|\/$/g, "")}/`)
|
|
26
|
+
: undefined
|
|
27
|
+
const sections = import.meta.env.CONFIG.sections ?? []
|
|
28
|
+
|
|
29
|
+
if (!sections.length) return null
|
|
30
|
+
const hasDesktopItems = sections.some(s => s.position === "sidebar")
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<SidebarGroup className={hasDesktopItems ? undefined : "md:hidden"}>
|
|
34
|
+
<SidebarGroupLabel className="uppercase font-mono text-xs tracking-widest">
|
|
35
|
+
Sections
|
|
36
|
+
</SidebarGroupLabel>
|
|
37
|
+
<SidebarGroupContent>
|
|
38
|
+
<SidebarMenu>
|
|
39
|
+
{sections.map(s => {
|
|
40
|
+
const target =
|
|
41
|
+
s.type === "blog" || s.type === "changelog"
|
|
42
|
+
? s.prefix
|
|
43
|
+
: (sectionFirstArticle.get(s.prefix) ?? s.prefix)
|
|
44
|
+
const active = section?.prefix === s.prefix
|
|
45
|
+
const mobileOnly = s.position !== "sidebar"
|
|
46
|
+
return (
|
|
47
|
+
<SidebarMenuItem
|
|
48
|
+
key={s.prefix}
|
|
49
|
+
className={mobileOnly ? "md:hidden" : undefined}
|
|
50
|
+
>
|
|
51
|
+
<SidebarMenuButton
|
|
52
|
+
isActive={active}
|
|
53
|
+
className={active ? "" : "opacity-75"}
|
|
54
|
+
render={
|
|
55
|
+
<Link
|
|
56
|
+
to={target === "/" ? "/" : "/$"}
|
|
57
|
+
params={target && target !== "/" ? splatFor(target) : {}}
|
|
58
|
+
/>
|
|
59
|
+
}
|
|
60
|
+
>
|
|
61
|
+
<DynamicIcon name={sectionIcon(s)} className="size-4" />
|
|
62
|
+
<span>{s.title}</span>
|
|
63
|
+
</SidebarMenuButton>
|
|
64
|
+
</SidebarMenuItem>
|
|
65
|
+
)
|
|
66
|
+
})}
|
|
67
|
+
</SidebarMenu>
|
|
68
|
+
</SidebarGroupContent>
|
|
69
|
+
</SidebarGroup>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
import defaultLogo from "../assets/logo.svg"
|
|
2
|
+
|
|
3
|
+
/** Renders the site logo, title, and description in the sidebar header */
|
|
2
4
|
export function SiteTitle() {
|
|
5
|
+
const logo = import.meta.env.CONFIG.logo ?? defaultLogo
|
|
3
6
|
return (
|
|
4
|
-
|
|
7
|
+
<div className="flex items-end gap-2.5 text-sm">
|
|
5
8
|
<img
|
|
6
|
-
src=
|
|
7
|
-
alt={import.meta.env.
|
|
8
|
-
className="size-
|
|
9
|
+
src={logo}
|
|
10
|
+
alt={import.meta.env.CONFIG.title}
|
|
11
|
+
className="size-6.5 mb-1.25"
|
|
9
12
|
/>
|
|
10
13
|
<div className="flex flex-col gap-0.5 leading-none">
|
|
11
|
-
<span className="font-semibold">{import.meta.env.
|
|
12
|
-
<span className="text-xs">
|
|
14
|
+
<span className="font-semibold">{import.meta.env.CONFIG.title}</span>
|
|
15
|
+
<span className="text-xs opacity-80">
|
|
16
|
+
{import.meta.env.CONFIG.description}
|
|
17
|
+
</span>
|
|
13
18
|
</div>
|
|
14
|
-
|
|
19
|
+
</div>
|
|
15
20
|
)
|
|
16
21
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Link } from "@tanstack/react-router"
|
|
2
|
+
import { History } from "lucide-react"
|
|
3
|
+
import { sortedArticles } from "../content/article.ts"
|
|
4
|
+
import { sectionTags } from "../content/tag.ts"
|
|
5
|
+
import { Separator } from "../elements/separator.tsx"
|
|
6
|
+
import { TocContext } from "../helpers/toc-context.ts"
|
|
7
|
+
import { Footer } from "./Footer.tsx"
|
|
8
|
+
import { PageToolbar } from "./PageToolbar.tsx"
|
|
9
|
+
import { MobileToc, Toc } from "./Toc.tsx"
|
|
10
|
+
|
|
11
|
+
/** Blog tag page listing posts filtered by a specific tag */
|
|
12
|
+
export function TagIndex(props: { sectionPrefix: string; tag: string }) {
|
|
13
|
+
const tagMap = sectionTags.get(props.sectionPrefix)
|
|
14
|
+
const paths = tagMap?.get(props.tag) ?? []
|
|
15
|
+
const posts = paths
|
|
16
|
+
.map(p => sortedArticles.find(a => a.path === p))
|
|
17
|
+
.filter(a => a !== undefined)
|
|
18
|
+
|
|
19
|
+
const tocItems = posts.map(post => ({
|
|
20
|
+
url: `#${slugOf(post.path)}`,
|
|
21
|
+
title: post.title,
|
|
22
|
+
depth: 2,
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
const title = `Posts tagged '${props.tag}'`
|
|
26
|
+
|
|
27
|
+
const content = [
|
|
28
|
+
`# ${title}`,
|
|
29
|
+
...posts.map(post => {
|
|
30
|
+
const parts = [`## ${post.title}`]
|
|
31
|
+
if (post.date) parts.push(formatDate(post.date))
|
|
32
|
+
if (post.author) {
|
|
33
|
+
parts.push(
|
|
34
|
+
Array.isArray(post.author) ? post.author.join(", ") : post.author,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
if (post.description) parts.push("", post.description)
|
|
38
|
+
return parts.join("\n")
|
|
39
|
+
}),
|
|
40
|
+
].join("\n\n")
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<TocContext.Provider value={tocItems}>
|
|
44
|
+
<MobileToc items={tocItems}>
|
|
45
|
+
<PageToolbar content={content} />
|
|
46
|
+
</MobileToc>
|
|
47
|
+
<div className="flex flex-1 gap-10 p-4 pt-8 md:p-10">
|
|
48
|
+
<div className="flex-1 min-w-0 mx-auto max-w-3xl">
|
|
49
|
+
<div className="prose dark:prose-invert max-w-none mb-8">
|
|
50
|
+
<h1 id="top">
|
|
51
|
+
<a href="#top">Posts tagged ‘{props.tag}’</a>
|
|
52
|
+
</h1>
|
|
53
|
+
{posts[0]?.date && (
|
|
54
|
+
<p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground !-mt-4 !mb-0">
|
|
55
|
+
<span className="flex items-center gap-1.5">
|
|
56
|
+
<History className="size-3.5" />
|
|
57
|
+
Updated {formatDate(posts[0].date)}
|
|
58
|
+
</span>
|
|
59
|
+
</p>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
<div className="space-y-6">
|
|
63
|
+
{posts.map((post, i) => (
|
|
64
|
+
<article key={post.path} id={slugOf(post.path)}>
|
|
65
|
+
{i > 0 && <Separator className="mb-6" />}
|
|
66
|
+
<h2 className="text-2xl">
|
|
67
|
+
<Link
|
|
68
|
+
to="/$"
|
|
69
|
+
params={{ _splat: post.path.replace(/^\/|\/$/g, "") }}
|
|
70
|
+
className="font-medium hover:text-primary transition-colors"
|
|
71
|
+
>
|
|
72
|
+
{post.title}
|
|
73
|
+
</Link>
|
|
74
|
+
</h2>
|
|
75
|
+
<p className="text-sm text-muted-foreground mt-1 flex gap-3">
|
|
76
|
+
{post.date && <span>{formatDate(post.date)}</span>}
|
|
77
|
+
{post.author && (
|
|
78
|
+
<span>
|
|
79
|
+
{Array.isArray(post.author)
|
|
80
|
+
? post.author.join(", ")
|
|
81
|
+
: post.author}
|
|
82
|
+
</span>
|
|
83
|
+
)}
|
|
84
|
+
</p>
|
|
85
|
+
{post.description && (
|
|
86
|
+
<p className="text-muted-foreground mt-2">
|
|
87
|
+
{post.description}
|
|
88
|
+
</p>
|
|
89
|
+
)}
|
|
90
|
+
</article>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
<div className="mt-10">
|
|
94
|
+
<Footer />
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
<Toc items={tocItems}>
|
|
98
|
+
<PageToolbar content={content} />
|
|
99
|
+
</Toc>
|
|
100
|
+
</div>
|
|
101
|
+
</TocContext.Provider>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function slugOf(path: string) {
|
|
106
|
+
return (
|
|
107
|
+
path
|
|
108
|
+
.replace(/^\/|\/$/g, "")
|
|
109
|
+
.split("/")
|
|
110
|
+
.pop() ?? ""
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function formatDate(iso: string) {
|
|
115
|
+
return new Date(iso).toLocaleDateString("en-US", {
|
|
116
|
+
year: "numeric",
|
|
117
|
+
month: "short",
|
|
118
|
+
day: "numeric",
|
|
119
|
+
})
|
|
120
|
+
}
|
|
@@ -18,7 +18,6 @@ export function Theme() {
|
|
|
18
18
|
size="sm"
|
|
19
19
|
onClick={handleToggle}
|
|
20
20
|
title="Change Theme"
|
|
21
|
-
aria-label="Toggle color scheme"
|
|
22
21
|
className="w-full h-auto rounded-xl cursor-pointer text-xs font-normal justify-start text-muted-foreground bg-sidebar-accent/70 border border-border shadow-xs hover:!bg-background px-3 py-2"
|
|
23
22
|
>
|
|
24
23
|
<div className="flex flex-1 gap-2 items-center dark:hidden">
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { ChevronDown } from "lucide-react"
|
|
1
2
|
import { useEffect, useRef, useState, type ReactNode } from "react"
|
|
2
|
-
import type { TocItem } from "../
|
|
3
|
+
import type { TocItem } from "../models/toc.ts"
|
|
3
4
|
|
|
4
5
|
export function Toc(props: { items: TocItem[]; children?: ReactNode }) {
|
|
5
6
|
const activeId = useActiveHeading(props.items)
|
|
@@ -33,6 +34,132 @@ export function Toc(props: { items: TocItem[]; children?: ReactNode }) {
|
|
|
33
34
|
)
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/** Mobile-only sticky secondary bar showing the active heading and an
|
|
38
|
+
* overlay dropdown with the Contents + page Actions. */
|
|
39
|
+
export function MobileToc(props: { items: TocItem[]; children?: ReactNode }) {
|
|
40
|
+
const activeId = useActiveHeading(props.items)
|
|
41
|
+
const [open, setOpen] = useState(false)
|
|
42
|
+
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!open) return
|
|
46
|
+
const onPointerDown = (event: MouseEvent) => {
|
|
47
|
+
if (!wrapperRef.current?.contains(event.target as Node)) setOpen(false)
|
|
48
|
+
}
|
|
49
|
+
const onKey = (event: KeyboardEvent) => {
|
|
50
|
+
if (event.key === "Escape") setOpen(false)
|
|
51
|
+
}
|
|
52
|
+
document.addEventListener("mousedown", onPointerDown)
|
|
53
|
+
document.addEventListener("keydown", onKey)
|
|
54
|
+
return () => {
|
|
55
|
+
document.removeEventListener("mousedown", onPointerDown)
|
|
56
|
+
document.removeEventListener("keydown", onKey)
|
|
57
|
+
}
|
|
58
|
+
}, [open])
|
|
59
|
+
|
|
60
|
+
if (props.items.length === 0 && !props.children) return null
|
|
61
|
+
|
|
62
|
+
const active = props.items.find(item => item.url.slice(1) === activeId)
|
|
63
|
+
const title = active?.title ?? props.items[0]?.title ?? "Contents"
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
ref={wrapperRef}
|
|
68
|
+
className="xl:hidden sticky top-16 z-20 border-b bg-background"
|
|
69
|
+
>
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={() => setOpen(v => !v)}
|
|
73
|
+
aria-expanded={open}
|
|
74
|
+
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-muted-foreground"
|
|
75
|
+
>
|
|
76
|
+
<ScrollProgressRing />
|
|
77
|
+
<span className="truncate flex-1 text-left">{title}</span>
|
|
78
|
+
<ChevronDown
|
|
79
|
+
className={`size-4 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
|
80
|
+
/>
|
|
81
|
+
</button>
|
|
82
|
+
{open && (
|
|
83
|
+
<div className="absolute top-full left-0 right-0 mt-px max-h-[calc(100vh-8rem)] overflow-auto border-b bg-background shadow-md animate-in slide-in-from-top-2 fade-in-0 duration-200">
|
|
84
|
+
<div className="space-y-4 px-4 pt-4 pb-4">
|
|
85
|
+
{props.children}
|
|
86
|
+
{props.items.length > 0 && (
|
|
87
|
+
<nav>
|
|
88
|
+
<p className="mb-2 text-sm font-medium">Contents</p>
|
|
89
|
+
<ul className="border-l border-border text-sm">
|
|
90
|
+
{props.items.map(item => (
|
|
91
|
+
<li key={item.url}>
|
|
92
|
+
<a
|
|
93
|
+
href={item.url}
|
|
94
|
+
data-active={
|
|
95
|
+
activeId === item.url.slice(1) || undefined
|
|
96
|
+
}
|
|
97
|
+
onClick={() => setOpen(false)}
|
|
98
|
+
className={`-ms-px block border-l border-transparent py-1.5 text-muted-foreground transition-colors hover:text-foreground data-[active]:border-primary data-[active]:font-medium data-[active]:text-primary ${item.depth <= 2 ? "ps-3" : item.depth === 3 ? "ps-6" : "ps-8"}`}
|
|
99
|
+
>
|
|
100
|
+
{item.title}
|
|
101
|
+
</a>
|
|
102
|
+
</li>
|
|
103
|
+
))}
|
|
104
|
+
</ul>
|
|
105
|
+
</nav>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function ScrollProgressRing() {
|
|
115
|
+
const progress = useScrollProgress()
|
|
116
|
+
const radius = 6
|
|
117
|
+
const circumference = 2 * Math.PI * radius
|
|
118
|
+
const offset = circumference * (1 - progress)
|
|
119
|
+
return (
|
|
120
|
+
<svg viewBox="0 0 16 16" className="size-4 shrink-0 -rotate-90" aria-hidden>
|
|
121
|
+
<circle
|
|
122
|
+
cx="8"
|
|
123
|
+
cy="8"
|
|
124
|
+
r={radius}
|
|
125
|
+
className="stroke-muted-foreground/30"
|
|
126
|
+
strokeWidth="2"
|
|
127
|
+
fill="none"
|
|
128
|
+
/>
|
|
129
|
+
<circle
|
|
130
|
+
cx="8"
|
|
131
|
+
cy="8"
|
|
132
|
+
r={radius}
|
|
133
|
+
className="stroke-primary transition-[stroke-dashoffset] duration-150"
|
|
134
|
+
strokeWidth="2"
|
|
135
|
+
fill="none"
|
|
136
|
+
strokeLinecap="round"
|
|
137
|
+
strokeDasharray={circumference}
|
|
138
|
+
strokeDashoffset={offset}
|
|
139
|
+
/>
|
|
140
|
+
</svg>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function useScrollProgress() {
|
|
145
|
+
const [progress, setProgress] = useState(0)
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
const update = () => {
|
|
148
|
+
const max = document.documentElement.scrollHeight - window.innerHeight
|
|
149
|
+
const next = max > 0 ? window.scrollY / max : 0
|
|
150
|
+
setProgress(Math.min(1, Math.max(0, next)))
|
|
151
|
+
}
|
|
152
|
+
update()
|
|
153
|
+
window.addEventListener("scroll", update, { passive: true })
|
|
154
|
+
window.addEventListener("resize", update)
|
|
155
|
+
return () => {
|
|
156
|
+
window.removeEventListener("scroll", update)
|
|
157
|
+
window.removeEventListener("resize", update)
|
|
158
|
+
}
|
|
159
|
+
}, [])
|
|
160
|
+
return progress
|
|
161
|
+
}
|
|
162
|
+
|
|
36
163
|
function useActiveHeading(items: TocItem[]) {
|
|
37
164
|
const [activeId, setActiveId] = useState<string>("")
|
|
38
165
|
const observer = useRef<IntersectionObserver | null>(null)
|