livemark 0.2.0 → 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/LICENSE.md +9 -0
- 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.js +66 -0
- package/build/actions/config/define.d.ts +6 -0
- package/build/actions/config/define.js +8 -0
- package/build/actions/config/define.unit.js +29 -0
- package/build/actions/config/load.d.ts +5 -0
- package/build/actions/config/load.js +25 -0
- package/build/actions/config/load.unit.js +23 -0
- package/build/commands/build.d.ts +5 -0
- package/build/commands/build.js +14 -0
- package/build/commands/escape.d.ts +9 -0
- package/build/commands/escape.js +56 -0
- package/build/commands/preview.d.ts +5 -0
- package/build/commands/preview.js +18 -0
- package/build/commands/start.d.ts +5 -0
- package/build/commands/start.js +19 -0
- package/build/docs/markdown/includes/example.d.ts +58 -0
- package/build/docs/markdown/includes/example.js +5 -0
- package/build/entrypoints/main.d.ts +2 -0
- package/build/entrypoints/main.js +12 -0
- package/build/helpers/program.d.ts +2 -0
- package/build/helpers/program.js +5 -0
- package/build/index.d.ts +1 -4
- package/build/index.js +2 -5
- package/build/livemark.config.d.ts +58 -0
- package/build/livemark.config.js +25 -0
- package/build/models/config.d.ts +340 -0
- package/build/models/config.js +34 -0
- 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.d.ts +2 -0
- package/build/program.js +16 -0
- package/build/vite.config.d.ts +2 -0
- package/build/vite.config.js +48 -0
- package/build/website/assets/logo.svg +117 -0
- package/build/website/client.tsx +10 -0
- package/build/website/components/Abbr.tsx +17 -0
- package/build/website/components/AnsiCode.tsx +15 -0
- package/build/website/components/Article.tsx +172 -0
- package/build/website/components/BackToTop.tsx +32 -0
- package/build/website/components/Banner.tsx +32 -0
- package/build/website/components/BlogIndex.tsx +121 -0
- package/build/website/components/BlogSidebar.tsx +162 -0
- package/build/website/components/Breadcrumbs.tsx +22 -0
- package/build/website/components/Callout.tsx +72 -0
- package/build/website/components/Card.tsx +48 -0
- package/build/website/components/Cards.tsx +8 -0
- package/build/website/components/ChangelogIndex.tsx +111 -0
- package/build/website/components/ChangelogSidebar.tsx +120 -0
- package/build/website/components/CodeBlock.tsx +135 -0
- package/build/website/components/CodeTabs.tsx +83 -0
- package/build/website/components/Column.tsx +4 -0
- package/build/website/components/Columns.tsx +12 -0
- package/build/website/components/Content.tsx +7 -0
- package/build/website/components/ContentTab.tsx +7 -0
- package/build/website/components/ContentTabs.tsx +82 -0
- package/build/website/components/DefaultCatchBoundary.tsx +54 -0
- package/build/website/components/DefinitionList.tsx +21 -0
- package/build/website/components/Details.tsx +30 -0
- package/build/website/components/FileTree.tsx +52 -0
- package/build/website/components/Footer.tsx +16 -0
- package/build/website/components/FootnoteRef.tsx +52 -0
- package/build/website/components/Heading.tsx +20 -0
- package/build/website/components/InlineBadge.tsx +9 -0
- package/build/website/components/InlineIcon.tsx +10 -0
- package/build/website/components/InlineToc.tsx +31 -0
- package/build/website/components/Layout.tsx +190 -0
- package/build/website/components/LinkButton.tsx +31 -0
- package/build/website/components/Mermaid.tsx +18 -0
- package/build/website/components/MermaidRenderer.tsx +37 -0
- package/build/website/components/MiniSidebar.tsx +49 -0
- package/build/website/components/NotFound.tsx +27 -0
- package/build/website/components/PackageTabs.tsx +74 -0
- package/build/website/components/PageToolbar.tsx +75 -0
- package/build/website/components/PrevNext.tsx +60 -0
- package/build/website/components/Search.tsx +28 -0
- package/build/website/components/SearchDialog.tsx +245 -0
- package/build/website/components/Section.tsx +24 -0
- package/build/website/components/SectionHeader.tsx +35 -0
- 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 +21 -0
- package/build/website/components/SortableTable.tsx +81 -0
- package/build/website/components/SoundCloud.tsx +16 -0
- package/build/website/components/TagIndex.tsx +120 -0
- package/build/website/components/Theme.tsx +39 -0
- package/build/website/components/Toc.tsx +226 -0
- package/build/website/components/YouTube.tsx +16 -0
- package/build/website/components/ZoomImage.tsx +25 -0
- package/build/website/content/article.ts +78 -0
- package/build/website/content/tag.ts +29 -0
- package/build/website/content-collections.ts +208 -0
- package/build/website/elements/accordion.tsx +78 -0
- package/build/website/elements/alert-dialog.tsx +185 -0
- package/build/website/elements/alert.tsx +74 -0
- package/build/website/elements/aspect-ratio.tsx +22 -0
- package/build/website/elements/avatar.tsx +107 -0
- package/build/website/elements/badge.tsx +51 -0
- package/build/website/elements/breadcrumb.tsx +120 -0
- package/build/website/elements/button-group.tsx +87 -0
- package/build/website/elements/button.tsx +57 -0
- package/build/website/elements/calendar.tsx +229 -0
- package/build/website/elements/card.tsx +102 -0
- package/build/website/elements/carousel.tsx +240 -0
- package/build/website/elements/chart.tsx +372 -0
- package/build/website/elements/checkbox.tsx +26 -0
- package/build/website/elements/collapsible.tsx +19 -0
- package/build/website/elements/command.tsx +210 -0
- package/build/website/elements/context-menu.tsx +270 -0
- package/build/website/elements/dialog.tsx +152 -0
- package/build/website/elements/direction.tsx +4 -0
- package/build/website/elements/drawer.tsx +127 -0
- package/build/website/elements/dropdown-menu.tsx +260 -0
- package/build/website/elements/empty.tsx +104 -0
- package/build/website/elements/field.tsx +235 -0
- package/build/website/elements/hover-card.tsx +49 -0
- package/build/website/elements/input-group.tsx +171 -0
- package/build/website/elements/input-otp.tsx +84 -0
- package/build/website/elements/input.tsx +18 -0
- package/build/website/elements/item.tsx +199 -0
- package/build/website/elements/kbd.tsx +26 -0
- package/build/website/elements/label.tsx +17 -0
- package/build/website/elements/menubar.tsx +282 -0
- package/build/website/elements/native-select.tsx +56 -0
- package/build/website/elements/navigation-menu.tsx +171 -0
- package/build/website/elements/pagination.tsx +133 -0
- package/build/website/elements/popover.tsx +88 -0
- package/build/website/elements/progress.tsx +81 -0
- package/build/website/elements/radio-group.tsx +36 -0
- package/build/website/elements/resizable.tsx +48 -0
- package/build/website/elements/scroll-area.tsx +51 -0
- package/build/website/elements/select.tsx +200 -0
- package/build/website/elements/separator.tsx +22 -0
- package/build/website/elements/sheet.tsx +130 -0
- package/build/website/elements/sidebar.tsx +717 -0
- package/build/website/elements/skeleton.tsx +13 -0
- package/build/website/elements/slider.tsx +57 -0
- package/build/website/elements/sonner.tsx +40 -0
- package/build/website/elements/spinner.tsx +16 -0
- package/build/website/elements/switch.tsx +30 -0
- package/build/website/elements/table.tsx +112 -0
- package/build/website/elements/tabs.tsx +79 -0
- package/build/website/elements/textarea.tsx +16 -0
- package/build/website/elements/toggle-group.tsx +87 -0
- package/build/website/elements/toggle.tsx +43 -0
- package/build/website/elements/tooltip.tsx +67 -0
- package/build/website/env.d.ts +14 -0
- package/build/website/helpers/article-icon.ts +61 -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/resolve-includes.ts +131 -0
- package/build/website/helpers/resolve-includes.unit.ts +126 -0
- package/build/website/helpers/search-text.ts +15 -0
- package/build/website/helpers/section.ts +50 -0
- package/build/website/helpers/section.unit.ts +71 -0
- package/build/website/helpers/style.ts +6 -0
- package/build/website/helpers/theme.ts +27 -0
- package/build/website/helpers/toc-context.ts +10 -0
- package/build/website/helpers/toc.ts +46 -0
- package/build/website/helpers/toc.unit.ts +74 -0
- package/build/website/hooks/in-view.ts +30 -0
- package/build/website/hooks/media.ts +19 -0
- package/build/website/models/article.ts +55 -0
- package/build/website/models/toc.ts +5 -0
- package/build/website/plugins/rehype-inline-code.ts +107 -0
- package/build/website/plugins/rehype-task-list-a11y.ts +33 -0
- package/build/website/plugins/remark-abbr.ts +46 -0
- package/build/website/plugins/remark-abbr.unit.ts +99 -0
- package/build/website/plugins/remark-ansi.ts +248 -0
- package/build/website/plugins/remark-ansi.unit.ts +197 -0
- package/build/website/plugins/remark-badge.ts +50 -0
- package/build/website/plugins/remark-badge.unit.ts +96 -0
- package/build/website/plugins/remark-button.ts +60 -0
- package/build/website/plugins/remark-button.unit.ts +147 -0
- package/build/website/plugins/remark-callout.ts +57 -0
- package/build/website/plugins/remark-card.ts +80 -0
- package/build/website/plugins/remark-code-tabs.ts +87 -0
- package/build/website/plugins/remark-columns.ts +66 -0
- package/build/website/plugins/remark-custom-heading-id.ts +70 -0
- package/build/website/plugins/remark-custom-heading-id.unit.ts +131 -0
- package/build/website/plugins/remark-deflist.ts +36 -0
- package/build/website/plugins/remark-details.ts +49 -0
- package/build/website/plugins/remark-filetree.ts +89 -0
- package/build/website/plugins/remark-filetree.unit.ts +137 -0
- package/build/website/plugins/remark-github-callout.ts +71 -0
- package/build/website/plugins/remark-icon.ts +42 -0
- package/build/website/plugins/remark-image.ts +34 -0
- package/build/website/plugins/remark-media.ts +62 -0
- package/build/website/plugins/remark-media.unit.ts +74 -0
- package/build/website/plugins/remark-mermaid.ts +23 -0
- package/build/website/plugins/remark-npm.ts +52 -0
- package/build/website/plugins/remark-steps.ts +36 -0
- package/build/website/plugins/remark-steps.unit.ts +97 -0
- package/build/website/plugins/remark-tab.ts +100 -0
- package/build/website/plugins/remark-tab.unit.ts +181 -0
- package/build/website/plugins/remark-toc.ts +39 -0
- package/build/website/plugins/remark-toc.unit.ts +82 -0
- package/build/website/plugins/shiki-icon.ts +82 -0
- package/build/website/plugins/shiki-line-highlight.ts +174 -0
- package/build/website/plugins/vite-livemark.ts +91 -0
- package/build/website/routeTree.gen.ts +86 -0
- package/build/website/router.tsx +34 -0
- package/build/website/routes/$.tsx +92 -0
- package/build/website/routes/__root.tsx +97 -0
- package/build/website/server.ts +7 -0
- package/build/website/styles/general.css +163 -0
- package/build/website/styles/markdown.css +214 -0
- package/build/website/vite.config.ts +71 -0
- package/entrypoints/main.ts +12 -0
- package/package.json +120 -12
- package/build/descriptor/index.d.ts +0 -3
- package/build/descriptor/index.js +0 -4
- package/build/descriptor/load.d.ts +0 -8
- package/build/descriptor/load.js +0 -40
- package/build/descriptor/node.d.ts +0 -125
- package/build/descriptor/node.js +0 -9
- package/build/descriptor/save.d.ts +0 -8
- package/build/descriptor/save.js +0 -17
- package/build/descriptor/save.spec.js +0 -80
- package/build/descriptor/validate.d.ts +0 -9
- package/build/descriptor/validate.js +0 -14
- package/build/descriptor/validate.spec.js +0 -124
- package/build/dialect/Dialect.d.ts +0 -88
- package/build/dialect/Dialect.js +0 -2
- package/build/dialect/index.d.ts +0 -3
- package/build/dialect/index.js +0 -3
- package/build/dialect/load.d.ts +0 -4
- package/build/dialect/load.js +0 -7
- package/build/dialect/save.d.ts +0 -9
- package/build/dialect/save.js +0 -13
- package/build/dialect/validate.d.ts +0 -1
- package/build/dialect/validate.js +0 -4
- package/build/metadata/index.d.ts +0 -3
- package/build/metadata/index.js +0 -4
- package/build/metadata/load.d.ts +0 -10
- package/build/metadata/load.js +0 -55
- package/build/metadata/node.d.ts +0 -125
- package/build/metadata/node.js +0 -9
- package/build/metadata/save.d.ts +0 -8
- package/build/metadata/save.js +0 -17
- package/build/metadata/save.spec.js +0 -80
- package/build/metadata/validate.d.ts +0 -9
- package/build/metadata/validate.js +0 -14
- package/build/metadata/validate.spec.d.ts +0 -1
- package/build/metadata/validate.spec.js +0 -124
- package/build/package/Contributor.d.ts +0 -21
- package/build/package/Contributor.js +0 -2
- package/build/package/Package.d.ts +0 -65
- package/build/package/Package.js +0 -2
- package/build/package/index.d.ts +0 -4
- package/build/package/index.js +0 -3
- package/build/package/load.d.ts +0 -4
- package/build/package/load.js +0 -7
- package/build/package/save.d.ts +0 -9
- package/build/package/save.js +0 -13
- package/build/package/validate.d.ts +0 -1
- package/build/package/validate.js +0 -4
- package/build/resource/License.d.ts +0 -18
- package/build/resource/License.js +0 -2
- package/build/resource/Resource.d.ts +0 -67
- package/build/resource/Resource.js +0 -2
- package/build/resource/Source.d.ts +0 -17
- package/build/resource/Source.js +0 -2
- package/build/resource/index.d.ts +0 -5
- package/build/resource/index.js +0 -2
- package/build/resource/load.d.ts +0 -1
- package/build/resource/load.js +0 -2
- package/build/resource/save.d.ts +0 -9
- package/build/resource/save.js +0 -13
- package/build/resource/types/index.d.ts +0 -1
- package/build/resource/types/index.js +0 -2
- package/build/resource/types/table.d.ts +0 -29
- package/build/resource/types/table.js +0 -2
- package/build/resource/validate.d.ts +0 -1
- package/build/resource/validate.js +0 -4
- package/build/schema/Field.d.ts +0 -5
- package/build/schema/Field.js +0 -2
- package/build/schema/Schema.d.ts +0 -55
- package/build/schema/Schema.js +0 -2
- package/build/schema/fields/any.d.ts +0 -20
- package/build/schema/fields/any.js +0 -2
- package/build/schema/fields/array.d.ts +0 -32
- package/build/schema/fields/array.js +0 -2
- package/build/schema/fields/base.d.ts +0 -42
- package/build/schema/fields/base.js +0 -2
- package/build/schema/fields/boolean.d.ts +0 -28
- package/build/schema/fields/boolean.js +0 -2
- package/build/schema/fields/date.d.ts +0 -35
- package/build/schema/fields/date.js +0 -2
- package/build/schema/fields/datetime.d.ts +0 -35
- package/build/schema/fields/datetime.js +0 -2
- package/build/schema/fields/duration.d.ts +0 -28
- package/build/schema/fields/duration.js +0 -2
- package/build/schema/fields/geojson.d.ts +0 -26
- package/build/schema/fields/geojson.js +0 -2
- package/build/schema/fields/geopoint.d.ts +0 -27
- package/build/schema/fields/geopoint.js +0 -2
- package/build/schema/fields/index.d.ts +0 -16
- package/build/schema/fields/index.js +0 -17
- package/build/schema/fields/integer.d.ts +0 -37
- package/build/schema/fields/integer.js +0 -2
- package/build/schema/fields/list.d.ts +0 -36
- package/build/schema/fields/list.js +0 -2
- package/build/schema/fields/number.d.ts +0 -40
- package/build/schema/fields/number.js +0 -2
- package/build/schema/fields/object.d.ts +0 -32
- package/build/schema/fields/object.js +0 -2
- package/build/schema/fields/string.d.ts +0 -40
- package/build/schema/fields/string.js +0 -2
- package/build/schema/fields/time.d.ts +0 -35
- package/build/schema/fields/time.js +0 -2
- package/build/schema/fields/year.d.ts +0 -28
- package/build/schema/fields/year.js +0 -2
- package/build/schema/fields/yearmonth.d.ts +0 -28
- package/build/schema/fields/yearmonth.js +0 -2
- package/build/schema/index.d.ts +0 -4
- package/build/schema/index.js +0 -2
- package/build/schema/load.d.ts +0 -1
- package/build/schema/load.js +0 -2
- package/build/schema/save.d.ts +0 -9
- package/build/schema/save.js +0 -13
- package/build/schema/validate.d.ts +0 -1
- package/build/schema/validate.js +0 -4
- package/build/tsconfig.tsbuildinfo +0 -1
- package/index.ts +0 -0
- package/tsconfig.json +0 -10
- package/typedoc.json +0 -4
- /package/build/{descriptor/save.spec.d.ts → actions/changelog/build.unit.d.ts} +0 -0
- /package/build/{descriptor/validate.spec.d.ts → actions/config/define.unit.d.ts} +0 -0
- /package/build/{metadata/save.spec.d.ts → actions/config/load.unit.d.ts} +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import defaultLogo from "../assets/logo.svg"
|
|
2
|
+
|
|
3
|
+
/** Renders the site logo, title, and description in the sidebar header */
|
|
4
|
+
export function SiteTitle() {
|
|
5
|
+
const logo = import.meta.env.CONFIG.logo ?? defaultLogo
|
|
6
|
+
return (
|
|
7
|
+
<div className="flex items-end gap-2.5 text-sm">
|
|
8
|
+
<img
|
|
9
|
+
src={logo}
|
|
10
|
+
alt={import.meta.env.CONFIG.title}
|
|
11
|
+
className="size-6.5 mb-1.25"
|
|
12
|
+
/>
|
|
13
|
+
<div className="flex flex-col gap-0.5 leading-none">
|
|
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>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useEffect, useRef, type ComponentProps } from "react"
|
|
2
|
+
import {
|
|
3
|
+
Table,
|
|
4
|
+
TableHeader,
|
|
5
|
+
TableBody,
|
|
6
|
+
TableRow,
|
|
7
|
+
TableHead,
|
|
8
|
+
TableCell,
|
|
9
|
+
} from "../elements/table.tsx"
|
|
10
|
+
|
|
11
|
+
/** Wraps markdown tables with click-to-sort column headers */
|
|
12
|
+
export function SortableTable(props: ComponentProps<"table">) {
|
|
13
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const container = ref.current
|
|
17
|
+
if (!container) return
|
|
18
|
+
|
|
19
|
+
let activeCol = -1
|
|
20
|
+
let ascending = true
|
|
21
|
+
|
|
22
|
+
const headers = container.querySelectorAll<HTMLElement>("th")
|
|
23
|
+
const cleanups: (() => void)[] = []
|
|
24
|
+
|
|
25
|
+
headers.forEach((th, colIndex) => {
|
|
26
|
+
th.style.cursor = "pointer"
|
|
27
|
+
th.classList.add("select-none")
|
|
28
|
+
|
|
29
|
+
const handler = () => {
|
|
30
|
+
if (activeCol === colIndex) {
|
|
31
|
+
ascending = !ascending
|
|
32
|
+
} else {
|
|
33
|
+
activeCol = colIndex
|
|
34
|
+
ascending = true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const tbody = container.querySelector("tbody")
|
|
38
|
+
if (!tbody) return
|
|
39
|
+
|
|
40
|
+
const rows = Array.from(tbody.querySelectorAll("tr"))
|
|
41
|
+
rows.sort((a, b) => {
|
|
42
|
+
const aVal = a.children[colIndex]?.textContent?.trim() ?? ""
|
|
43
|
+
const bVal = b.children[colIndex]?.textContent?.trim() ?? ""
|
|
44
|
+
const aNum = Number(aVal)
|
|
45
|
+
const bNum = Number(bVal)
|
|
46
|
+
const cmp =
|
|
47
|
+
aVal !== "" && bVal !== "" && !isNaN(aNum) && !isNaN(bNum)
|
|
48
|
+
? aNum - bNum
|
|
49
|
+
: aVal.localeCompare(bVal)
|
|
50
|
+
return ascending ? cmp : -cmp
|
|
51
|
+
})
|
|
52
|
+
for (const row of rows) tbody.appendChild(row)
|
|
53
|
+
|
|
54
|
+
headers.forEach((h, i) => {
|
|
55
|
+
const indicator = h.querySelector("[data-sort]")
|
|
56
|
+
if (indicator) indicator.remove()
|
|
57
|
+
if (i === colIndex) {
|
|
58
|
+
const span = document.createElement("span")
|
|
59
|
+
span.dataset.sort = ""
|
|
60
|
+
span.className = "ml-1 text-muted-foreground"
|
|
61
|
+
span.textContent = ascending ? "↑" : "↓"
|
|
62
|
+
h.appendChild(span)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
th.addEventListener("click", handler)
|
|
68
|
+
cleanups.push(() => th.removeEventListener("click", handler))
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
return () => cleanups.forEach(fn => fn())
|
|
72
|
+
}, [])
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div ref={ref} className="not-prose">
|
|
76
|
+
<Table {...props} />
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export { TableHeader, TableBody, TableRow, TableHead, TableCell }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Renders a SoundCloud iframe embed */
|
|
2
|
+
export function SoundCloud(props: { url: string }) {
|
|
3
|
+
const src = `https://w.soundcloud.com/player/?url=${encodeURIComponent(props.url)}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false`
|
|
4
|
+
|
|
5
|
+
return (
|
|
6
|
+
<div className="not-prose my-4 overflow-hidden rounded-lg border">
|
|
7
|
+
<iframe
|
|
8
|
+
className="w-full"
|
|
9
|
+
height={166}
|
|
10
|
+
src={src}
|
|
11
|
+
title="SoundCloud audio"
|
|
12
|
+
allow="autoplay"
|
|
13
|
+
/>
|
|
14
|
+
</div>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Moon, Sun } from "lucide-react"
|
|
2
|
+
import { useHotkey } from "@tanstack/react-hotkeys"
|
|
3
|
+
import { Button } from "../elements/button.tsx"
|
|
4
|
+
import { getCurrentTheme, setTheme } from "../helpers/theme.ts"
|
|
5
|
+
|
|
6
|
+
export function Theme() {
|
|
7
|
+
const handleToggle = () => {
|
|
8
|
+
const oldTheme = getCurrentTheme()
|
|
9
|
+
const newTheme = oldTheme === "light" ? "dark" : "light"
|
|
10
|
+
setTheme(newTheme)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
useHotkey("T", handleToggle)
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<Button
|
|
17
|
+
variant="ghost"
|
|
18
|
+
size="sm"
|
|
19
|
+
onClick={handleToggle}
|
|
20
|
+
title="Change Theme"
|
|
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"
|
|
22
|
+
>
|
|
23
|
+
<div className="flex flex-1 gap-2 items-center dark:hidden">
|
|
24
|
+
<Sun className="size-4" />
|
|
25
|
+
<span className="flex-1 text-left">Light Theme</span>
|
|
26
|
+
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-mono">
|
|
27
|
+
T
|
|
28
|
+
</kbd>
|
|
29
|
+
</div>
|
|
30
|
+
<div className="flex-1 gap-2 items-center hidden dark:flex">
|
|
31
|
+
<Moon className="size-4" />
|
|
32
|
+
<span className="flex-1 text-left">Dark Theme</span>
|
|
33
|
+
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-mono">
|
|
34
|
+
T
|
|
35
|
+
</kbd>
|
|
36
|
+
</div>
|
|
37
|
+
</Button>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { ChevronDown } from "lucide-react"
|
|
2
|
+
import { useEffect, useRef, useState, type ReactNode } from "react"
|
|
3
|
+
import type { TocItem } from "../models/toc.ts"
|
|
4
|
+
|
|
5
|
+
export function Toc(props: { items: TocItem[]; children?: ReactNode }) {
|
|
6
|
+
const activeId = useActiveHeading(props.items)
|
|
7
|
+
|
|
8
|
+
if (props.items.length === 0 && !props.children) return null
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<aside className="hidden xl:block w-56 shrink-0">
|
|
12
|
+
<div className="sticky top-20 max-h-[calc(100vh-5rem)] overflow-auto [scrollbar-width:none]">
|
|
13
|
+
{props.children}
|
|
14
|
+
{props.items.length > 0 && (
|
|
15
|
+
<nav>
|
|
16
|
+
<p className="text-sm font-medium mb-3">Contents</p>
|
|
17
|
+
<ul className="text-sm border-l border-border">
|
|
18
|
+
{props.items.map(item => (
|
|
19
|
+
<li key={item.url}>
|
|
20
|
+
<a
|
|
21
|
+
href={item.url}
|
|
22
|
+
data-active={activeId === item.url.slice(1) || undefined}
|
|
23
|
+
className={`-ms-px block py-1.5 border-l border-transparent text-muted-foreground transition-colors hover:text-foreground data-[active]:border-primary data-[active]:text-primary data-[active]:font-medium ${item.depth <= 2 ? "ps-3" : item.depth === 3 ? "ps-6" : "ps-8"}`}
|
|
24
|
+
>
|
|
25
|
+
{item.title}
|
|
26
|
+
</a>
|
|
27
|
+
</li>
|
|
28
|
+
))}
|
|
29
|
+
</ul>
|
|
30
|
+
</nav>
|
|
31
|
+
)}
|
|
32
|
+
</div>
|
|
33
|
+
</aside>
|
|
34
|
+
)
|
|
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
|
+
|
|
163
|
+
function useActiveHeading(items: TocItem[]) {
|
|
164
|
+
const [activeId, setActiveId] = useState<string>("")
|
|
165
|
+
const observer = useRef<IntersectionObserver | null>(null)
|
|
166
|
+
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
const ids = items.map(item => item.url.slice(1))
|
|
169
|
+
const elements = ids
|
|
170
|
+
.map(id => document.getElementById(id))
|
|
171
|
+
.filter(Boolean) as HTMLElement[]
|
|
172
|
+
|
|
173
|
+
if (elements.length === 0) return
|
|
174
|
+
|
|
175
|
+
const visibleSet = new Map<string, number>()
|
|
176
|
+
|
|
177
|
+
observer.current = new IntersectionObserver(
|
|
178
|
+
entries => {
|
|
179
|
+
for (const entry of entries) {
|
|
180
|
+
if (entry.isIntersecting) {
|
|
181
|
+
visibleSet.set(entry.target.id, entry.time)
|
|
182
|
+
} else {
|
|
183
|
+
visibleSet.delete(entry.target.id)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (visibleSet.size > 0) {
|
|
188
|
+
let latest = ""
|
|
189
|
+
let latestTime = 0
|
|
190
|
+
for (const [id, time] of visibleSet) {
|
|
191
|
+
if (time > latestTime) {
|
|
192
|
+
latest = id
|
|
193
|
+
latestTime = time
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
setActiveId(latest)
|
|
197
|
+
} else {
|
|
198
|
+
const viewTop = window.scrollY
|
|
199
|
+
let closest = ""
|
|
200
|
+
let closestDist = Number.POSITIVE_INFINITY
|
|
201
|
+
for (const el of elements) {
|
|
202
|
+
const dist = Math.abs(
|
|
203
|
+
viewTop - el.getBoundingClientRect().top - window.scrollY,
|
|
204
|
+
)
|
|
205
|
+
if (dist < closestDist) {
|
|
206
|
+
closest = el.id
|
|
207
|
+
closestDist = dist
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
setActiveId(closest)
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
{ threshold: 0.98 },
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
for (const el of elements) {
|
|
217
|
+
observer.current.observe(el)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return () => {
|
|
221
|
+
observer.current?.disconnect()
|
|
222
|
+
}
|
|
223
|
+
}, [items])
|
|
224
|
+
|
|
225
|
+
return activeId
|
|
226
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Renders a responsive YouTube iframe embed */
|
|
2
|
+
export function YouTube(props: { id: string }) {
|
|
3
|
+
return (
|
|
4
|
+
<div className="not-prose my-4 overflow-hidden rounded-lg border">
|
|
5
|
+
<div className="aspect-video">
|
|
6
|
+
<iframe
|
|
7
|
+
className="h-full w-full"
|
|
8
|
+
src={`https://www.youtube.com/embed/${props.id}`}
|
|
9
|
+
title="YouTube video"
|
|
10
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
11
|
+
allowFullScreen
|
|
12
|
+
/>
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useEffect, useState, type ComponentProps } from "react"
|
|
2
|
+
import Zoom from "react-medium-image-zoom"
|
|
3
|
+
import "react-medium-image-zoom/dist/styles.css"
|
|
4
|
+
|
|
5
|
+
/** Image component with click-to-zoom and light/dark theme variant support */
|
|
6
|
+
export function ZoomImage(props: ComponentProps<"img">) {
|
|
7
|
+
const [mounted, setMounted] = useState(false)
|
|
8
|
+
useEffect(() => setMounted(true), [])
|
|
9
|
+
|
|
10
|
+
const src = props.src ?? ""
|
|
11
|
+
let imgProps = props
|
|
12
|
+
if (src.endsWith("#light")) {
|
|
13
|
+
imgProps = { ...props, src: src.slice(0, -6), className: "dark:hidden" }
|
|
14
|
+
} else if (src.endsWith("#dark")) {
|
|
15
|
+
imgProps = {
|
|
16
|
+
...props,
|
|
17
|
+
src: src.slice(0, -5),
|
|
18
|
+
className: "hidden dark:block",
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const img = <img {...imgProps} alt={imgProps.alt ?? ""} />
|
|
23
|
+
if (!mounted) return img
|
|
24
|
+
return <Zoom>{img}</Zoom>
|
|
25
|
+
}
|