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.
Files changed (94) hide show
  1. package/README.md +5 -1
  2. package/build/actions/changelog/build.d.ts +14 -0
  3. package/build/actions/changelog/build.js +158 -0
  4. package/build/actions/changelog/build.unit.d.ts +1 -0
  5. package/build/actions/changelog/build.unit.js +66 -0
  6. package/build/actions/config/define.unit.js +11 -16
  7. package/build/actions/config/load.js +6 -3
  8. package/build/actions/config/load.unit.js +3 -3
  9. package/build/commands/build.js +4 -3
  10. package/build/commands/escape.d.ts +9 -0
  11. package/build/commands/escape.js +56 -0
  12. package/build/docs/markdown/includes/example.d.ts +53 -4
  13. package/build/docs/markdown/includes/example.js +2 -2
  14. package/build/livemark.config.d.ts +53 -4
  15. package/build/livemark.config.js +22 -2
  16. package/build/models/config.d.ts +315 -8
  17. package/build/models/config.js +25 -5
  18. package/build/models/link.d.ts +13 -0
  19. package/build/models/link.js +9 -0
  20. package/build/models/patch.d.ts +21 -0
  21. package/build/models/patch.js +7 -0
  22. package/build/models/section.d.ts +75 -0
  23. package/build/models/section.js +26 -0
  24. package/build/models/theme.d.ts +72 -0
  25. package/build/models/theme.js +73 -0
  26. package/build/program.js +4 -2
  27. package/build/vite.config.d.ts +1 -1
  28. package/build/website/assets/logo.svg +117 -0
  29. package/build/website/components/Article.tsx +172 -0
  30. package/build/website/components/BackToTop.tsx +32 -0
  31. package/build/website/components/Banner.tsx +2 -2
  32. package/build/website/components/BlogIndex.tsx +121 -0
  33. package/build/website/components/BlogSidebar.tsx +162 -0
  34. package/build/website/components/Breadcrumbs.tsx +5 -2
  35. package/build/website/components/Card.tsx +48 -0
  36. package/build/website/components/Cards.tsx +0 -58
  37. package/build/website/components/ChangelogIndex.tsx +111 -0
  38. package/build/website/components/ChangelogSidebar.tsx +120 -0
  39. package/build/website/components/CodeBlock.tsx +57 -40
  40. package/build/website/components/Column.tsx +4 -0
  41. package/build/website/components/Columns.tsx +0 -5
  42. package/build/website/components/Footer.tsx +12 -5
  43. package/build/website/components/InlineIcon.tsx +2 -13
  44. package/build/website/components/Layout.tsx +155 -185
  45. package/build/website/components/Mermaid.tsx +14 -33
  46. package/build/website/components/MermaidRenderer.tsx +37 -0
  47. package/build/website/components/MiniSidebar.tsx +49 -0
  48. package/build/website/components/PageToolbar.tsx +21 -11
  49. package/build/website/components/PrevNext.tsx +20 -12
  50. package/build/website/components/Search.tsx +28 -0
  51. package/build/website/components/SearchDialog.tsx +10 -10
  52. package/build/website/components/Sidebar.tsx +160 -0
  53. package/build/website/components/SidebarLinks.tsx +70 -0
  54. package/build/website/components/SidebarSections.tsx +71 -0
  55. package/build/website/components/SiteTitle.tsx +13 -8
  56. package/build/website/components/TagIndex.tsx +120 -0
  57. package/build/website/components/Theme.tsx +0 -1
  58. package/build/website/components/Toc.tsx +128 -1
  59. package/build/website/components/ZoomImage.tsx +15 -20
  60. package/build/website/content/article.ts +78 -0
  61. package/build/website/content/tag.ts +29 -0
  62. package/build/website/content-collections.ts +53 -26
  63. package/build/website/elements/sidebar.tsx +4 -4
  64. package/build/website/env.d.ts +4 -0
  65. package/build/website/helpers/article-icon.ts +2 -0
  66. package/build/website/helpers/article.ts +90 -0
  67. package/build/website/helpers/changelog-source.ts +16 -0
  68. package/build/website/helpers/changelog-source.unit.ts +38 -0
  69. package/build/website/helpers/dynamic-icon-resolver.tsx +19 -0
  70. package/build/website/helpers/dynamic-icon.tsx +12 -0
  71. package/build/website/helpers/favicon.ts +7 -0
  72. package/build/website/helpers/model.ts +4 -0
  73. package/build/website/helpers/resolve-asset-path.ts +9 -0
  74. package/build/website/helpers/section.ts +50 -0
  75. package/build/website/helpers/section.unit.ts +71 -0
  76. package/build/website/helpers/toc-context.ts +1 -1
  77. package/build/website/helpers/toc.ts +1 -6
  78. package/build/website/models/article.ts +48 -33
  79. package/build/website/models/toc.ts +5 -0
  80. package/build/website/plugins/rehype-task-list-a11y.ts +33 -0
  81. package/build/website/plugins/remark-ansi.ts +1 -0
  82. package/build/website/plugins/remark-image.ts +22 -13
  83. package/build/website/plugins/vite-livemark.ts +91 -0
  84. package/build/website/routeTree.gen.ts +9 -9
  85. package/build/website/routes/$.tsx +74 -131
  86. package/build/website/routes/__root.tsx +36 -23
  87. package/build/website/styles/general.css +26 -210
  88. package/build/website/styles/markdown.css +214 -0
  89. package/build/website/vite.config.ts +38 -3
  90. package/package.json +25 -16
  91. package/build/website/globals.d.ts +0 -8
  92. package/build/website/helpers/articles.ts +0 -24
  93. package/build/website/public/logo.svg +0 -72
  94. 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 "../helpers/articles.ts"
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
- pathname: string
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
- pathname: article.pathname,
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.pathname),
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
- pathname: "",
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
- (pathname: string) => {
107
+ (path: string) => {
108
108
  props.onOpenChange(false)
109
109
  setQuery("")
110
110
  setResults([])
111
111
  navigate({
112
112
  to: "/$",
113
- params: { _splat: pathname.replace(/^\/|\/$/g, "") },
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.pathname}
157
- value={result.pathname}
158
- onSelect={() => handleSelect(result.pathname)}
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
- /** Renders the site title and description in the sidebar header */
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="/logo.svg"
7
- alt={import.meta.env.SITE_TITLE}
8
- className="size-8 rounded-lg"
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.SITE_TITLE}</span>
12
- <span className="text-xs">{import.meta.env.SITE_DESCRIPTION}</span>
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 &lsquo;{props.tag}&rsquo;</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 "../helpers/toc.ts"
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)