livemark 0.25.0 → 0.27.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 +3 -2
- package/build/actions/config/define.d.ts +12 -0
- package/build/actions/config/define.unit.js +8 -1
- package/build/actions/escape/copy.d.ts +11 -0
- package/build/actions/escape/copy.js +29 -0
- package/build/actions/escape/copy.unit.d.ts +1 -0
- package/build/actions/escape/copy.unit.js +37 -0
- package/build/actions/escape/list.d.ts +12 -0
- package/build/actions/escape/list.js +27 -0
- package/build/actions/escape/list.unit.d.ts +1 -0
- package/build/actions/escape/list.unit.js +36 -0
- package/build/actions/target/build.d.ts +9 -26
- package/build/actions/target/build.js +50 -71
- package/build/actions/target/build.unit.d.ts +1 -0
- package/build/actions/target/build.unit.js +76 -0
- package/build/actions/target/clear.d.ts +4 -0
- package/build/actions/target/clear.js +14 -0
- package/build/actions/target/clear.unit.d.ts +1 -0
- package/build/actions/target/clear.unit.js +23 -0
- package/build/commands/build.js +1 -2
- package/build/commands/clear.d.ts +5 -0
- package/build/commands/clear.js +15 -0
- package/build/commands/escape.d.ts +4 -3
- package/build/commands/escape.js +45 -48
- package/build/commands/preview.js +1 -2
- package/build/commands/start.js +1 -2
- package/build/components/Abbr.d.ts +1 -1
- package/build/components/Abbr.js +2 -4
- package/build/components/AnsiCode.d.ts +1 -1
- package/build/components/AnsiCode.js +2 -2
- package/build/components/Article.d.ts +2 -1
- package/build/components/Article.js +7 -14
- package/build/components/BackToTop.d.ts +1 -1
- package/build/components/Banner.d.ts +2 -2
- package/build/components/Banner.js +7 -3
- package/build/components/BlogIndex.d.ts +1 -1
- package/build/components/BlogIndex.js +2 -11
- package/build/components/Breadcrumbs.d.ts +1 -1
- package/build/components/Breadcrumbs.js +2 -3
- package/build/components/Callout.d.ts +1 -1
- package/build/components/Callout.js +5 -9
- package/build/components/Card.d.ts +1 -1
- package/build/components/Card.js +2 -3
- package/build/components/Cards.d.ts +1 -1
- package/build/components/ChangelogIndex.d.ts +1 -1
- package/build/components/ChangelogIndex.js +2 -11
- package/build/components/ChoiceList.d.ts +15 -0
- package/build/components/ChoiceList.js +13 -0
- package/build/components/CodeBlock.d.ts +1 -1
- package/build/components/CodeBlock.js +3 -16
- package/build/components/CodeTabs.d.ts +1 -1
- package/build/components/CodeTabs.js +2 -3
- package/build/components/Column.d.ts +1 -1
- package/build/components/Columns.d.ts +1 -1
- package/build/components/Content.d.ts +1 -1
- package/build/components/ContentTab.d.ts +1 -1
- package/build/components/ContentTabs.d.ts +1 -1
- package/build/components/ContentTabs.js +2 -3
- package/build/components/DefaultCatchBoundary.d.ts +1 -1
- package/build/components/DefaultCatchBoundary.js +5 -7
- package/build/components/DefinitionList.d.ts +3 -3
- package/build/components/Details.d.ts +1 -1
- package/build/components/Details.js +2 -5
- package/build/components/FileTree.d.ts +1 -1
- package/build/components/FileTree.js +3 -5
- package/build/components/Footer.d.ts +1 -1
- package/build/components/Footer.js +2 -5
- package/build/components/FootnoteRef.d.ts +1 -1
- package/build/components/FootnoteRef.js +2 -4
- package/build/components/Header.d.ts +6 -0
- package/build/components/Header.js +26 -0
- package/build/components/HeaderMenu.d.ts +2 -0
- package/build/components/HeaderMenu.js +27 -0
- package/build/components/HeaderNav.d.ts +6 -0
- package/build/components/HeaderNav.js +74 -0
- package/build/components/HeaderSearch.d.ts +4 -0
- package/build/components/HeaderSearch.js +33 -0
- package/build/components/Heading.d.ts +6 -6
- package/build/components/InlineBadge.d.ts +2 -2
- package/build/components/InlineBadge.js +1 -1
- package/build/components/InlineIcon.d.ts +1 -1
- package/build/components/InlineToc.d.ts +1 -1
- package/build/components/InlineToc.js +2 -4
- package/build/components/Layout.d.ts +2 -1
- package/build/components/Layout.js +5 -58
- package/build/components/LinkButton.d.ts +2 -2
- package/build/components/LinkButton.js +1 -1
- package/build/components/MenuSheet.d.ts +22 -0
- package/build/components/MenuSheet.js +27 -0
- package/build/components/Mermaid.d.ts +1 -1
- package/build/components/MermaidRenderer.d.ts +1 -1
- package/build/components/MermaidRenderer.js +20 -8
- package/build/components/NotFound.d.ts +1 -1
- package/build/components/NotFound.js +3 -6
- package/build/components/PackageTabs.d.ts +1 -1
- package/build/components/PackageTabs.js +3 -7
- package/build/components/PageToolbar.d.ts +1 -1
- package/build/components/PageToolbar.js +2 -10
- package/build/components/PrevNext.d.ts +1 -1
- package/build/components/PrevNext.js +2 -10
- package/build/components/Root.d.ts +4 -0
- package/build/components/Root.js +8 -0
- package/build/components/Search.d.ts +5 -2
- package/build/components/Search.js +11 -9
- package/build/components/SearchDialog.d.ts +1 -1
- package/build/components/SearchDialog.js +10 -133
- package/build/components/Section.d.ts +1 -1
- package/build/components/SectionHeader.d.ts +1 -1
- package/build/components/SheetPanel.d.ts +12 -0
- package/build/components/SheetPanel.js +19 -0
- package/build/components/Sidebar.d.ts +1 -1
- package/build/components/Sidebar.js +12 -14
- package/build/components/SidebarArticles.d.ts +4 -1
- package/build/components/SidebarArticles.js +5 -9
- package/build/components/SidebarLinks.d.ts +4 -1
- package/build/components/SidebarLinks.js +6 -9
- package/build/components/SidebarPosts.d.ts +4 -1
- package/build/components/SidebarPosts.js +7 -23
- package/build/components/SidebarReleases.d.ts +4 -1
- package/build/components/SidebarReleases.js +5 -15
- package/build/components/SidebarSectionPicker.d.ts +8 -0
- package/build/components/SidebarSectionPicker.js +31 -0
- package/build/components/SidebarSections.d.ts +1 -1
- package/build/components/SidebarSections.js +5 -9
- package/build/components/SiteLink.d.ts +1 -1
- package/build/components/SiteTitle.d.ts +1 -1
- package/build/components/SiteTitle.js +2 -6
- package/build/components/SortableTable.d.ts +1 -1
- package/build/components/SoundCloud.d.ts +1 -1
- package/build/components/SoundCloud.js +2 -2
- package/build/components/TagIndex.d.ts +1 -1
- package/build/components/TagIndex.js +2 -11
- package/build/components/Theme.d.ts +2 -1
- package/build/components/Theme.js +35 -15
- package/build/components/Toc.d.ts +5 -4
- package/build/components/Toc.js +35 -35
- package/build/components/YouTube.d.ts +1 -1
- package/build/components/YouTube.js +2 -2
- package/build/components/ZoomImage.d.ts +1 -1
- package/build/content/article.js +4 -11
- package/build/content-collections.js +6 -4
- package/build/elements/accordion.d.ts +4 -4
- package/build/elements/accordion.js +2 -3
- package/build/elements/alert-dialog.d.ts +12 -12
- package/build/elements/alert-dialog.js +2 -4
- package/build/elements/alert.d.ts +4 -4
- package/build/elements/alert.js +2 -2
- package/build/elements/aspect-ratio.d.ts +1 -1
- package/build/elements/avatar.d.ts +6 -6
- package/build/elements/badge.d.ts +1 -1
- package/build/elements/badge.js +3 -1
- package/build/elements/breadcrumb.d.ts +6 -6
- package/build/elements/breadcrumb.js +2 -4
- package/build/elements/button-group.d.ts +2 -2
- package/build/elements/button-group.js +5 -5
- package/build/elements/button.d.ts +2 -2
- package/build/elements/button.js +9 -7
- package/build/elements/calendar.d.ts +2 -2
- package/build/elements/calendar.js +2 -2
- package/build/elements/card.d.ts +7 -7
- package/build/elements/card.js +4 -4
- package/build/elements/carousel.d.ts +5 -5
- package/build/elements/carousel.js +3 -7
- package/build/elements/chart.d.ts +6 -6
- package/build/elements/chart.js +5 -11
- package/build/elements/checkbox.d.ts +1 -1
- package/build/elements/collapsible.d.ts +3 -3
- package/build/elements/command.d.ts +9 -9
- package/build/elements/command.js +5 -11
- package/build/elements/context-menu.d.ts +15 -15
- package/build/elements/context-menu.js +6 -9
- package/build/elements/dialog.d.ts +10 -10
- package/build/elements/dialog.js +2 -6
- package/build/elements/drawer.d.ts +14 -12
- package/build/elements/drawer.js +14 -15
- package/build/elements/dropdown-menu.d.ts +15 -15
- package/build/elements/dropdown-menu.js +7 -10
- package/build/elements/empty.d.ts +6 -6
- package/build/elements/empty.js +2 -2
- package/build/elements/field.d.ts +10 -10
- package/build/elements/field.js +3 -4
- package/build/elements/hover-card.d.ts +3 -3
- package/build/elements/hover-card.js +2 -2
- package/build/elements/input-group.d.ts +7 -7
- package/build/elements/input-group.js +5 -5
- package/build/elements/input-otp.d.ts +4 -4
- package/build/elements/input.d.ts +1 -1
- package/build/elements/input.js +2 -2
- package/build/elements/item.d.ts +9 -9
- package/build/elements/item.js +2 -2
- package/build/elements/kbd.d.ts +2 -2
- package/build/elements/label.d.ts +1 -1
- package/build/elements/menubar.d.ts +16 -16
- package/build/elements/menubar.js +9 -11
- package/build/elements/native-select.d.ts +3 -3
- package/build/elements/native-select.js +2 -4
- package/build/elements/navigation-menu.d.ts +8 -8
- package/build/elements/navigation-menu.js +3 -5
- package/build/elements/pagination.d.ts +7 -7
- package/build/elements/pagination.js +4 -10
- package/build/elements/popover.d.ts +6 -6
- package/build/elements/popover.js +2 -2
- package/build/elements/progress.d.ts +5 -5
- package/build/elements/progress.js +2 -3
- package/build/elements/radio-group.d.ts +2 -2
- package/build/elements/resizable.d.ts +3 -3
- package/build/elements/scroll-area.d.ts +2 -2
- package/build/elements/scroll-area.js +2 -4
- package/build/elements/select.d.ts +9 -9
- package/build/elements/select.js +4 -9
- package/build/elements/separator.d.ts +1 -1
- package/build/elements/sheet.d.ts +8 -8
- package/build/elements/sheet.js +2 -6
- package/build/elements/sidebar.d.ts +19 -19
- package/build/elements/sidebar.js +15 -37
- package/build/elements/skeleton.d.ts +1 -1
- package/build/elements/slider.d.ts +2 -1
- package/build/elements/slider.js +2 -3
- package/build/elements/sonner.d.ts +1 -1
- package/build/elements/spinner.d.ts +1 -1
- package/build/elements/switch.d.ts +1 -1
- package/build/elements/table.d.ts +8 -8
- package/build/elements/tabs.d.ts +4 -4
- package/build/elements/tabs.js +3 -3
- package/build/elements/textarea.d.ts +1 -1
- package/build/elements/textarea.js +2 -2
- package/build/elements/toggle-group.d.ts +3 -2
- package/build/elements/toggle-group.js +3 -3
- package/build/elements/toggle.d.ts +1 -1
- package/build/elements/toggle.js +2 -2
- package/build/elements/tooltip.d.ts +4 -4
- package/build/elements/tooltip.js +2 -3
- package/build/head.d.ts +4 -0
- package/build/head.js +6 -0
- package/build/helpers/agent-skills.d.ts +22 -0
- package/build/helpers/agent-skills.js +46 -0
- package/build/helpers/agent-skills.unit.d.ts +1 -0
- package/build/helpers/agent-skills.unit.js +49 -0
- package/build/helpers/article-search.d.ts +19 -0
- package/build/helpers/article-search.js +134 -0
- package/build/helpers/article.d.ts +13 -0
- package/build/helpers/article.js +17 -1
- package/build/helpers/dependency-dir.d.ts +5 -0
- package/build/helpers/dependency-dir.js +20 -0
- package/build/helpers/dependency-dir.unit.d.ts +1 -0
- package/build/helpers/dependency-dir.unit.js +25 -0
- package/build/helpers/dynamic-icon-resolver.d.ts +1 -1
- package/build/helpers/dynamic-icon.d.ts +1 -1
- package/build/helpers/head.d.ts +58 -0
- package/build/helpers/head.js +80 -0
- package/build/helpers/head.unit.d.ts +1 -0
- package/build/helpers/head.unit.js +110 -0
- package/build/helpers/llms.d.ts +33 -0
- package/build/helpers/llms.js +72 -0
- package/build/helpers/llms.unit.d.ts +1 -0
- package/build/helpers/llms.unit.js +98 -0
- package/build/helpers/nav-items.d.ts +11 -0
- package/build/helpers/nav-items.js +65 -0
- package/build/helpers/render-inline-markdown.d.ts +1 -0
- package/build/helpers/render-inline-markdown.js +34 -0
- package/build/helpers/render-inline-markdown.unit.d.ts +1 -0
- package/build/helpers/render-inline-markdown.unit.js +20 -0
- package/build/helpers/search-highlight.d.ts +8 -0
- package/build/helpers/search-highlight.js +59 -0
- package/build/helpers/search-highlight.unit.d.ts +1 -0
- package/build/helpers/search-highlight.unit.js +36 -0
- package/build/helpers/target-export.d.ts +9 -0
- package/build/helpers/target-export.js +24 -0
- package/build/helpers/target-export.unit.d.ts +1 -0
- package/build/helpers/target-export.unit.js +25 -0
- package/build/helpers/theme.d.ts +6 -2
- package/build/helpers/theme.js +40 -7
- package/build/helpers/theme.unit.d.ts +1 -0
- package/build/helpers/theme.unit.js +30 -0
- package/build/hooks/hydrated.d.ts +2 -0
- package/build/hooks/hydrated.js +15 -0
- package/build/index.d.ts +1 -0
- package/build/index.js +1 -1
- package/build/models/config.d.ts +52 -6
- package/build/models/config.js +17 -1
- package/build/models/section.d.ts +4 -2
- package/build/models/section.js +1 -1
- package/build/plugins/vite-agents.d.ts +17 -0
- package/build/plugins/vite-agents.js +109 -0
- package/build/plugins/vite-agents.unit.d.ts +1 -0
- package/build/plugins/vite-agents.unit.js +20 -0
- package/build/plugins/vite-livemark.d.ts +4 -5
- package/build/plugins/vite-livemark.js +56 -90
- package/build/plugins/vite-robots.d.ts +4 -2
- package/build/plugins/vite-robots.js +15 -2
- package/build/plugins/vite-robots.unit.js +5 -1
- package/build/program.js +4 -2
- package/build/routes/$.d.ts +4 -1
- package/build/routes/$.js +20 -9
- package/build/routes/__root.js +29 -63
- package/build/settings.js +2 -1
- package/build/vite.config.js +3 -1
- package/package.json +69 -66
- package/{.agents/skills → skills}/livemark-authoring/SKILL.md +2 -2
- package/{.agents/skills → skills}/livemark-configuration/SKILL.md +16 -5
- package/skills/livemark-customization/SKILL.md +146 -0
- package/source/actions/config/define.unit.ts +9 -0
- package/source/actions/escape/copy.ts +35 -0
- package/source/actions/escape/copy.unit.ts +51 -0
- package/source/actions/escape/list.ts +30 -0
- package/source/actions/escape/list.unit.ts +42 -0
- package/source/actions/target/build.ts +49 -69
- package/source/actions/target/build.unit.ts +95 -0
- package/source/actions/target/clear.ts +14 -0
- package/source/actions/target/clear.unit.ts +25 -0
- package/source/commands/build.ts +0 -1
- package/source/commands/clear.ts +15 -0
- package/source/commands/escape.ts +42 -59
- package/source/commands/preview.ts +0 -1
- package/source/commands/start.ts +0 -1
- package/source/components/AnsiCode.tsx +1 -1
- package/source/components/Article.tsx +8 -4
- package/source/components/Banner.tsx +12 -2
- package/source/components/BlogIndex.tsx +1 -1
- package/source/components/Callout.tsx +4 -6
- package/source/components/ChangelogIndex.tsx +1 -1
- package/source/components/ChoiceList.tsx +69 -0
- package/source/components/CodeBlock.tsx +6 -6
- package/source/components/CodeTabs.tsx +1 -1
- package/source/components/ContentTabs.tsx +1 -1
- package/source/components/DefaultCatchBoundary.tsx +4 -6
- package/source/components/Details.tsx +1 -1
- package/source/components/FileTree.tsx +1 -1
- package/source/components/Header.tsx +52 -0
- package/source/components/HeaderMenu.tsx +41 -0
- package/source/components/HeaderNav.tsx +145 -0
- package/source/components/HeaderSearch.tsx +77 -0
- package/source/components/InlineBadge.tsx +1 -1
- package/source/components/InlineToc.tsx +1 -1
- package/source/components/Layout.tsx +16 -160
- package/source/components/LinkButton.tsx +9 -1
- package/source/components/MenuSheet.tsx +92 -0
- package/source/components/MermaidRenderer.tsx +19 -8
- package/source/components/NotFound.tsx +3 -5
- package/source/components/PackageTabs.tsx +2 -2
- package/source/components/PageToolbar.tsx +11 -3
- package/source/components/PrevNext.tsx +2 -2
- package/source/components/Root.tsx +13 -0
- package/source/components/Search.tsx +28 -8
- package/source/components/SearchDialog.tsx +15 -189
- package/source/components/SheetPanel.tsx +69 -0
- package/source/components/Sidebar.tsx +27 -14
- package/source/components/SidebarArticles.tsx +4 -3
- package/source/components/SidebarLinks.tsx +3 -2
- package/source/components/SidebarPosts.tsx +3 -2
- package/source/components/SidebarReleases.tsx +3 -2
- package/source/components/SidebarSectionPicker.tsx +70 -0
- package/source/components/SiteTitle.tsx +5 -5
- package/source/components/SoundCloud.tsx +1 -1
- package/source/components/TagIndex.tsx +1 -1
- package/source/components/Theme.tsx +55 -29
- package/source/components/Toc.tsx +76 -55
- package/source/components/YouTube.tsx +1 -1
- package/source/content/article.ts +8 -13
- package/source/content-collections.ts +5 -4
- package/source/elements/alert-dialog.tsx +1 -1
- package/source/elements/alert.tsx +1 -1
- package/source/elements/badge.tsx +4 -0
- package/source/elements/button-group.tsx +4 -4
- package/source/elements/button.tsx +10 -6
- package/source/elements/calendar.tsx +1 -1
- package/source/elements/card.tsx +3 -3
- package/source/elements/command.tsx +7 -4
- package/source/elements/context-menu.tsx +5 -5
- package/source/elements/dialog.tsx +1 -1
- package/source/elements/drawer.tsx +59 -43
- package/source/elements/dropdown-menu.tsx +6 -6
- package/source/elements/empty.tsx +1 -1
- package/source/elements/field.tsx +1 -1
- package/source/elements/hover-card.tsx +1 -1
- package/source/elements/input-group.tsx +4 -4
- package/source/elements/input.tsx +1 -1
- package/source/elements/item.tsx +1 -1
- package/source/elements/menubar.tsx +8 -8
- package/source/elements/native-select.tsx +1 -1
- package/source/elements/popover.tsx +1 -1
- package/source/elements/select.tsx +3 -3
- package/source/elements/sidebar.tsx +8 -21
- package/source/elements/tabs.tsx +2 -2
- package/source/elements/textarea.tsx +1 -1
- package/source/elements/toggle-group.tsx +2 -2
- package/source/elements/toggle.tsx +1 -1
- package/source/env.d.ts +1 -0
- package/source/head.ts +7 -0
- package/source/helpers/agent-skills.ts +59 -0
- package/source/helpers/agent-skills.unit.ts +56 -0
- package/source/helpers/article-search.tsx +192 -0
- package/source/helpers/article.ts +17 -0
- package/source/helpers/dependency-dir.ts +20 -0
- package/source/helpers/dependency-dir.unit.ts +39 -0
- package/source/helpers/head.ts +105 -0
- package/source/helpers/head.unit.ts +118 -0
- package/source/helpers/llms.ts +109 -0
- package/source/helpers/llms.unit.ts +115 -0
- package/source/helpers/nav-items.tsx +102 -0
- package/source/helpers/render-inline-markdown.ts +44 -0
- package/source/helpers/render-inline-markdown.unit.ts +30 -0
- package/source/helpers/search-highlight.ts +73 -0
- package/source/helpers/search-highlight.unit.ts +42 -0
- package/source/helpers/target-export.ts +28 -0
- package/source/helpers/target-export.unit.ts +37 -0
- package/source/helpers/theme.ts +40 -6
- package/source/helpers/theme.unit.ts +32 -0
- package/source/hooks/hydrated.ts +18 -0
- package/source/index.ts +1 -0
- package/source/models/config.ts +24 -0
- package/source/models/section.ts +3 -0
- package/source/plugins/vite-agents.ts +135 -0
- package/source/plugins/vite-agents.unit.ts +26 -0
- package/source/plugins/vite-livemark.ts +54 -95
- package/source/plugins/vite-robots.ts +18 -3
- package/source/plugins/vite-robots.unit.ts +7 -0
- package/source/program.ts +2 -0
- package/source/routes/$.tsx +27 -9
- package/source/routes/__root.tsx +51 -71
- package/source/settings.ts +1 -0
- package/source/styles/general.css +59 -11
- package/source/styles/markdown.css +13 -7
- package/source/styles/shape.css +14 -0
- package/source/vite.config.ts +2 -0
- package/.agents/skills/change-review/SKILL.md +0 -47
- package/.agents/skills/livemark-customization/SKILL.md +0 -118
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Config } from "../models/config.ts"
|
|
2
|
+
import type { RoutedSection } from "../models/section.ts"
|
|
3
|
+
import {
|
|
4
|
+
buildArticleTree,
|
|
5
|
+
compareArticleDate,
|
|
6
|
+
compareArticleOrder,
|
|
7
|
+
flattenArticleTree,
|
|
8
|
+
} from "./article.ts"
|
|
9
|
+
import { partitionBySection } from "./section.ts"
|
|
10
|
+
|
|
11
|
+
/** The slice of a built article that agent-facing files are rendered from. */
|
|
12
|
+
export interface LlmsArticle {
|
|
13
|
+
path: string
|
|
14
|
+
title: string
|
|
15
|
+
description?: string
|
|
16
|
+
order?: number
|
|
17
|
+
date?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Render `llms.txt`: the site title and description, then one Markdown link
|
|
21
|
+
* per article twin, grouped under each routed section in sidebar order. */
|
|
22
|
+
export function renderLlmsText(props: {
|
|
23
|
+
config: Pick<Config, "title" | "description" | "site" | "base" | "sections">
|
|
24
|
+
articles: LlmsArticle[]
|
|
25
|
+
}) {
|
|
26
|
+
const lines = [`# ${props.config.title}`, "", `> ${props.config.description}`]
|
|
27
|
+
for (const group of groupArticles(props)) {
|
|
28
|
+
lines.push("", `## ${group.title}`, "", ...renderLinks(props.config, group.articles))
|
|
29
|
+
}
|
|
30
|
+
return `${lines.join("\n")}\n`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Render the Markdown twin of a section's index page (a blog or changelog
|
|
34
|
+
* listing), which has no article of its own: links to the section's articles. */
|
|
35
|
+
export function renderSectionIndex(props: {
|
|
36
|
+
config: Pick<Config, "site" | "base" | "sections">
|
|
37
|
+
section: RoutedSection
|
|
38
|
+
articles: LlmsArticle[]
|
|
39
|
+
}) {
|
|
40
|
+
const group = groupArticles(props).find(g => g.prefix === props.section.prefix)
|
|
41
|
+
const lines = [
|
|
42
|
+
`# ${props.section.title}`,
|
|
43
|
+
"",
|
|
44
|
+
...renderLinks(props.config, group?.articles ?? []),
|
|
45
|
+
]
|
|
46
|
+
return `${lines.join("\n")}\n`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Render the Markdown twin of an article: its title and description as the
|
|
50
|
+
* rendered page shows them, then the body without its own leading h1. */
|
|
51
|
+
export function renderMarkdownTwin(props: { article: LlmsArticle; content: string }) {
|
|
52
|
+
const article = props.article
|
|
53
|
+
const parts = [`# ${article.title}`]
|
|
54
|
+
if (article.description) parts.push(`> ${article.description}`)
|
|
55
|
+
parts.push(stripLeadingH1(props.content).trim())
|
|
56
|
+
return `${parts.join("\n\n")}\n`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Path of an article's Markdown twin inside the build output (no base). */
|
|
60
|
+
export function buildTwinFileName(path: string) {
|
|
61
|
+
return `${path.replace(/^\//, "")}index.md`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Drop a leading h1 so a title set by frontmatter or a patch is not shown twice */
|
|
65
|
+
export function stripLeadingH1(content: string) {
|
|
66
|
+
return content.replace(/^[ \t]*#\s+[^\n]*\n+/, "")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildTwinUrl(config: Pick<Config, "site" | "base">, path: string) {
|
|
70
|
+
const host = config.site?.replace(/\/+$/, "") ?? ""
|
|
71
|
+
return `${host}${config.base ?? ""}${path}index.md`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function groupArticles(props: {
|
|
75
|
+
config: Pick<Config, "sections">
|
|
76
|
+
articles: LlmsArticle[]
|
|
77
|
+
}) {
|
|
78
|
+
const sections =
|
|
79
|
+
props.config.sections?.filter((s): s is RoutedSection => s.type !== "custom") ?? []
|
|
80
|
+
const buckets = partitionBySection(
|
|
81
|
+
[...props.articles].sort(compareArticleOrder),
|
|
82
|
+
sections,
|
|
83
|
+
)
|
|
84
|
+
const groups = [
|
|
85
|
+
...sections.map(s => ({
|
|
86
|
+
prefix: s.prefix,
|
|
87
|
+
title: s.title,
|
|
88
|
+
dated: s.type !== "article",
|
|
89
|
+
})),
|
|
90
|
+
{ prefix: "__default__", title: sections.length ? "Other" : "Pages", dated: false },
|
|
91
|
+
]
|
|
92
|
+
return groups.flatMap(group => {
|
|
93
|
+
const bucket = buckets.get(group.prefix)
|
|
94
|
+
if (!bucket?.length) return []
|
|
95
|
+
const articles = group.dated
|
|
96
|
+
? [...bucket].sort(compareArticleDate)
|
|
97
|
+
: flattenArticleTree(buildArticleTree(bucket)).flatMap(
|
|
98
|
+
path => bucket.find(a => a.path === path) ?? [],
|
|
99
|
+
)
|
|
100
|
+
return [{ prefix: group.prefix, title: group.title, articles }]
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderLinks(config: Pick<Config, "site" | "base">, articles: LlmsArticle[]) {
|
|
105
|
+
return articles.map(article => {
|
|
106
|
+
const link = `[${article.title}](${buildTwinUrl(config, article.path)})`
|
|
107
|
+
return article.description ? `- ${link}: ${article.description}` : `- ${link}`
|
|
108
|
+
})
|
|
109
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test"
|
|
2
|
+
import type { RoutedSection } from "../models/section.ts"
|
|
3
|
+
import {
|
|
4
|
+
buildTwinFileName,
|
|
5
|
+
renderLlmsText,
|
|
6
|
+
renderMarkdownTwin,
|
|
7
|
+
renderSectionIndex,
|
|
8
|
+
} from "./llms.ts"
|
|
9
|
+
|
|
10
|
+
const config = { title: "Docs", description: "Blurb" }
|
|
11
|
+
|
|
12
|
+
describe("renderLlmsText", () => {
|
|
13
|
+
it("lists articles in sidebar order under a single heading without sections", () => {
|
|
14
|
+
const text = renderLlmsText({
|
|
15
|
+
config,
|
|
16
|
+
articles: [
|
|
17
|
+
{ path: "/guide/", title: "Guide", order: 2 },
|
|
18
|
+
{ path: "/intro/", title: "Intro", description: "Start here", order: 1 },
|
|
19
|
+
{ path: "/guide/deep/", title: "Deep" },
|
|
20
|
+
],
|
|
21
|
+
})
|
|
22
|
+
expect(text).toBe(
|
|
23
|
+
[
|
|
24
|
+
"# Docs",
|
|
25
|
+
"",
|
|
26
|
+
"> Blurb",
|
|
27
|
+
"",
|
|
28
|
+
"## Pages",
|
|
29
|
+
"",
|
|
30
|
+
"- [Intro](/intro/index.md): Start here",
|
|
31
|
+
"- [Guide](/guide/index.md)",
|
|
32
|
+
"- [Deep](/guide/deep/index.md)",
|
|
33
|
+
"",
|
|
34
|
+
].join("\n"),
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("groups by routed section, newest first in a blog, with absolute urls", () => {
|
|
39
|
+
const text = renderLlmsText({
|
|
40
|
+
config: {
|
|
41
|
+
...config,
|
|
42
|
+
site: "https://example.com/",
|
|
43
|
+
base: "/repo",
|
|
44
|
+
sections: [
|
|
45
|
+
{ title: "Docs", prefix: "/", type: "article", position: "header" },
|
|
46
|
+
{ title: "Blog", prefix: "/blog/", type: "blog", position: "header" },
|
|
47
|
+
{
|
|
48
|
+
title: "GitHub",
|
|
49
|
+
url: "https://github.com",
|
|
50
|
+
type: "custom",
|
|
51
|
+
position: "header",
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
articles: [
|
|
56
|
+
{ path: "/blog/old/", title: "Old", date: "2026-01-01" },
|
|
57
|
+
{ path: "/intro/", title: "Intro" },
|
|
58
|
+
{ path: "/blog/new/", title: "New", date: "2026-02-01" },
|
|
59
|
+
],
|
|
60
|
+
})
|
|
61
|
+
expect(text).toContain(
|
|
62
|
+
[
|
|
63
|
+
"## Docs",
|
|
64
|
+
"",
|
|
65
|
+
"- [Intro](https://example.com/repo/intro/index.md)",
|
|
66
|
+
"",
|
|
67
|
+
"## Blog",
|
|
68
|
+
"",
|
|
69
|
+
"- [New](https://example.com/repo/blog/new/index.md)",
|
|
70
|
+
"- [Old](https://example.com/repo/blog/old/index.md)",
|
|
71
|
+
].join("\n"),
|
|
72
|
+
)
|
|
73
|
+
expect(text).not.toContain("GitHub")
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe("renderSectionIndex", () => {
|
|
78
|
+
it("lists only the section's articles, newest first", () => {
|
|
79
|
+
const blog: RoutedSection = {
|
|
80
|
+
title: "Blog",
|
|
81
|
+
prefix: "/blog/",
|
|
82
|
+
type: "blog",
|
|
83
|
+
position: "header",
|
|
84
|
+
}
|
|
85
|
+
const text = renderSectionIndex({
|
|
86
|
+
config: { sections: [blog] },
|
|
87
|
+
section: blog,
|
|
88
|
+
articles: [
|
|
89
|
+
{ path: "/intro/", title: "Intro" },
|
|
90
|
+
{ path: "/blog/old/", title: "Old", date: "2026-01-01" },
|
|
91
|
+
{ path: "/blog/new/", title: "New", date: "2026-02-01", description: "Fresh" },
|
|
92
|
+
],
|
|
93
|
+
})
|
|
94
|
+
expect(text).toBe(
|
|
95
|
+
"# Blog\n\n- [New](/blog/new/index.md): Fresh\n- [Old](/blog/old/index.md)\n",
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe("renderMarkdownTwin", () => {
|
|
101
|
+
it("leads with the title and description instead of the body's own h1", () => {
|
|
102
|
+
const twin = renderMarkdownTwin({
|
|
103
|
+
article: { path: "/intro/", title: "Introduction", description: "Start here" },
|
|
104
|
+
content: "# Livemark\n\nBody text\n",
|
|
105
|
+
})
|
|
106
|
+
expect(twin).toBe("# Introduction\n\n> Start here\n\nBody text\n")
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe("buildTwinFileName", () => {
|
|
111
|
+
it("places the twin beside the page's index.html", () => {
|
|
112
|
+
expect(buildTwinFileName("/")).toBe("index.md")
|
|
113
|
+
expect(buildTwinFileName("/guide/deep/")).toBe("guide/deep/index.md")
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { config } from "livemark:virtual"
|
|
2
|
+
import { Link, useLocation } from "@tanstack/react-router"
|
|
3
|
+
import {
|
|
4
|
+
sectionFirstArticle,
|
|
5
|
+
sectionFlatArticles,
|
|
6
|
+
sortedArticles,
|
|
7
|
+
} from "../content/article.ts"
|
|
8
|
+
import { DynamicIcon } from "./dynamic-icon.tsx"
|
|
9
|
+
import { prefixUrl } from "./prefix-url.ts"
|
|
10
|
+
import { customSectionActive } from "./section.ts"
|
|
11
|
+
|
|
12
|
+
export type NavItem = {
|
|
13
|
+
key: string
|
|
14
|
+
title: string
|
|
15
|
+
icon?: string
|
|
16
|
+
suffix?: string
|
|
17
|
+
active: boolean
|
|
18
|
+
href?: string
|
|
19
|
+
target?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useNavItems(activePrefix?: string) {
|
|
23
|
+
const pathname = useLocation({ select: l => l.pathname })
|
|
24
|
+
const sections = config.sections ?? []
|
|
25
|
+
const items: NavItem[] = sections
|
|
26
|
+
.filter(section => section.position === "header")
|
|
27
|
+
.map(section => {
|
|
28
|
+
if (section.type === "custom") {
|
|
29
|
+
return {
|
|
30
|
+
key: section.url,
|
|
31
|
+
title: section.title,
|
|
32
|
+
icon: section.icon,
|
|
33
|
+
active: customSectionActive(section.url, pathname),
|
|
34
|
+
href: section.url,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const suffix =
|
|
38
|
+
section.type === "changelog" && section.version
|
|
39
|
+
? sortedArticles.find(
|
|
40
|
+
a => a.path === sectionFlatArticles.get(section.prefix)?.[0],
|
|
41
|
+
)?.title
|
|
42
|
+
: undefined
|
|
43
|
+
return {
|
|
44
|
+
key: section.prefix,
|
|
45
|
+
title: section.title,
|
|
46
|
+
icon: section.icon,
|
|
47
|
+
suffix,
|
|
48
|
+
active: activePrefix === section.prefix,
|
|
49
|
+
target:
|
|
50
|
+
section.type === "blog" || section.type === "changelog"
|
|
51
|
+
? section.prefix
|
|
52
|
+
: sectionFirstArticle.get(section.prefix),
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
if (!sections.length) {
|
|
56
|
+
items.push({ key: "/", title: "Docs", active: true, target: "/" })
|
|
57
|
+
}
|
|
58
|
+
for (const link of config.links ?? []) {
|
|
59
|
+
if (link.position !== "header") continue
|
|
60
|
+
if (link.prefix && link.prefix !== activePrefix) continue
|
|
61
|
+
items.push({
|
|
62
|
+
key: link.url,
|
|
63
|
+
title: link.title,
|
|
64
|
+
icon: link.icon,
|
|
65
|
+
active: false,
|
|
66
|
+
href: link.url,
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
return items
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function renderNavAnchor(item: NavItem, className?: string) {
|
|
73
|
+
const children = (
|
|
74
|
+
<>
|
|
75
|
+
{item.icon && <DynamicIcon name={item.icon} className="size-3.5" />}
|
|
76
|
+
<span>
|
|
77
|
+
{item.title}
|
|
78
|
+
{item.suffix && <span className="opacity-80"> ({item.suffix})</span>}
|
|
79
|
+
</span>
|
|
80
|
+
</>
|
|
81
|
+
)
|
|
82
|
+
if (item.href) {
|
|
83
|
+
return (
|
|
84
|
+
<a href={prefixUrl(item.href, config.base)} className={className}>
|
|
85
|
+
{children}
|
|
86
|
+
</a>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
return (
|
|
90
|
+
<Link
|
|
91
|
+
to={item.target === "/" ? "/" : "/$"}
|
|
92
|
+
params={item.target && item.target !== "/" ? splatFor(item.target) : {}}
|
|
93
|
+
className={className}
|
|
94
|
+
>
|
|
95
|
+
{children}
|
|
96
|
+
</Link>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function splatFor(pathname: string) {
|
|
101
|
+
return { _splat: pathname.replace(/^\/|\/$/g, "") }
|
|
102
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import rehypeStringify from "rehype-stringify"
|
|
2
|
+
import remarkGfm from "remark-gfm"
|
|
3
|
+
import remarkParse from "remark-parse"
|
|
4
|
+
import remarkRehype from "remark-rehype"
|
|
5
|
+
import { unified } from "unified"
|
|
6
|
+
import { visit } from "unist-util-visit"
|
|
7
|
+
import { prefixUrl } from "./prefix-url.ts"
|
|
8
|
+
|
|
9
|
+
interface HastNode {
|
|
10
|
+
type: string
|
|
11
|
+
tagName?: string
|
|
12
|
+
properties?: Record<string, unknown>
|
|
13
|
+
children?: HastNode[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function renderInlineMarkdown(markdown: string, base: string | undefined) {
|
|
17
|
+
const html = unified()
|
|
18
|
+
.use(remarkParse)
|
|
19
|
+
.use(remarkGfm)
|
|
20
|
+
.use(remarkRehype)
|
|
21
|
+
.use(() => (tree: HastNode) => rewriteLinks(tree, base))
|
|
22
|
+
.use(() => unwrapParagraphs)
|
|
23
|
+
.use(rehypeStringify)
|
|
24
|
+
.processSync(markdown)
|
|
25
|
+
return String(html).trim()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rewriteLinks(tree: HastNode, base: string | undefined) {
|
|
29
|
+
visit(tree, "element", (node: HastNode) => {
|
|
30
|
+
if (node.tagName !== "a" || typeof node.properties?.href !== "string") return
|
|
31
|
+
const href = node.properties.href
|
|
32
|
+
node.properties.href = prefixUrl(href, base)
|
|
33
|
+
if (/^https?:\/\//.test(href)) {
|
|
34
|
+
node.properties.target = "_blank"
|
|
35
|
+
node.properties.rel = ["noopener", "noreferrer"]
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function unwrapParagraphs(tree: HastNode) {
|
|
41
|
+
tree.children = (tree.children ?? []).flatMap(child =>
|
|
42
|
+
child.type === "element" && child.tagName === "p" ? (child.children ?? []) : [child],
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test"
|
|
2
|
+
import { renderInlineMarkdown } from "./render-inline-markdown.ts"
|
|
3
|
+
|
|
4
|
+
describe("renderInlineMarkdown", () => {
|
|
5
|
+
it("should render plain text without a paragraph", () => {
|
|
6
|
+
expect(renderInlineMarkdown("Hello world", undefined)).toBe("Hello world")
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it("should render bold, emphasis and code", () => {
|
|
10
|
+
expect(renderInlineMarkdown("**a** _b_ `c`", undefined)).toBe(
|
|
11
|
+
"<strong>a</strong> <em>b</em> <code>c</code>",
|
|
12
|
+
)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("should open absolute links in a new tab", () => {
|
|
16
|
+
expect(renderInlineMarkdown("[x](https://example.com)", undefined)).toBe(
|
|
17
|
+
'<a href="https://example.com" target="_blank" rel="noopener noreferrer">x</a>',
|
|
18
|
+
)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("should prefix root-relative links with the base", () => {
|
|
22
|
+
expect(renderInlineMarkdown("[x](/blog/)", "/repo")).toBe(
|
|
23
|
+
'<a href="/repo/blog/">x</a>',
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("should drop raw HTML tags", () => {
|
|
28
|
+
expect(renderInlineMarkdown("a <b onclick=x>c</b>", undefined)).toBe("a c")
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useEffect, type RefObject } from "react"
|
|
2
|
+
|
|
3
|
+
const HIGHLIGHT_NAME = "search-match"
|
|
4
|
+
|
|
5
|
+
/** Build a case-insensitive pattern matching each word of a search query */
|
|
6
|
+
export function buildHighlightPattern(query: string) {
|
|
7
|
+
const words = [...new Set(query.toLowerCase().split(/\s+/))]
|
|
8
|
+
.filter(word => word.length >= 2)
|
|
9
|
+
.sort((a, b) => b.length - a.length)
|
|
10
|
+
.map(word => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
11
|
+
if (!words.length) return undefined
|
|
12
|
+
return new RegExp(words.join("|"), "gi")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Find the `[start, end]` offsets of every pattern match in a text */
|
|
16
|
+
export function findTextMatches(text: string, pattern: RegExp) {
|
|
17
|
+
return [...text.matchAll(pattern)].map(
|
|
18
|
+
match => [match.index, match.index + match[0].length] as const,
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Highlight search query matches under `root` and scroll to the first visible one;
|
|
23
|
+
* `page` re-runs it when the same query lands on another page */
|
|
24
|
+
export function useSearchHighlight(
|
|
25
|
+
root: RefObject<HTMLElement | null>,
|
|
26
|
+
page: string,
|
|
27
|
+
query?: string,
|
|
28
|
+
) {
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const pattern = query ? buildHighlightPattern(query) : undefined
|
|
31
|
+
if (!pattern || !root.current || typeof CSS === "undefined" || !CSS.highlights) return
|
|
32
|
+
|
|
33
|
+
const ranges = collectMatchRanges(root.current, pattern)
|
|
34
|
+
CSS.highlights.set(HIGHLIGHT_NAME, new Highlight(...ranges))
|
|
35
|
+
|
|
36
|
+
// An explicit anchor in the URL wins over the first match. The frame delay lets
|
|
37
|
+
// the router's scroll restoration, which resets to the top, run first.
|
|
38
|
+
const frame = window.location.hash
|
|
39
|
+
? undefined
|
|
40
|
+
: requestAnimationFrame(() => scrollToFirstVisible(ranges))
|
|
41
|
+
|
|
42
|
+
return () => {
|
|
43
|
+
if (frame !== undefined) cancelAnimationFrame(frame)
|
|
44
|
+
CSS.highlights.delete(HIGHLIGHT_NAME)
|
|
45
|
+
}
|
|
46
|
+
}, [root, page, query])
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function collectMatchRanges(root: HTMLElement, pattern: RegExp) {
|
|
50
|
+
const ranges: Range[] = []
|
|
51
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
52
|
+
acceptNode: node =>
|
|
53
|
+
node.parentElement?.closest("pre, script, style, svg, textarea")
|
|
54
|
+
? NodeFilter.FILTER_REJECT
|
|
55
|
+
: NodeFilter.FILTER_ACCEPT,
|
|
56
|
+
})
|
|
57
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
58
|
+
for (const [start, end] of findTextMatches(node.textContent ?? "", pattern)) {
|
|
59
|
+
const range = new Range()
|
|
60
|
+
range.setStart(node, start)
|
|
61
|
+
range.setEnd(node, end)
|
|
62
|
+
ranges.push(range)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return ranges
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function scrollToFirstVisible(ranges: Range[]) {
|
|
69
|
+
const first = ranges.find(range =>
|
|
70
|
+
range.startContainer.parentElement?.checkVisibility(),
|
|
71
|
+
)
|
|
72
|
+
first?.startContainer.parentElement?.scrollIntoView({ block: "center" })
|
|
73
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test"
|
|
2
|
+
import { buildHighlightPattern, findTextMatches } from "./search-highlight.ts"
|
|
3
|
+
|
|
4
|
+
describe("buildHighlightPattern", () => {
|
|
5
|
+
it("should return undefined for an empty query", () => {
|
|
6
|
+
expect(buildHighlightPattern(" ")).toBeUndefined()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it("should drop single-character words", () => {
|
|
10
|
+
expect(buildHighlightPattern("a b")).toBeUndefined()
|
|
11
|
+
expect(buildHighlightPattern("a config")?.source).toBe("config")
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("should put longer words first and dedupe case-insensitively", () => {
|
|
15
|
+
expect(buildHighlightPattern("Side sidebar side")?.source).toBe("sidebar|side")
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("should escape regex syntax", () => {
|
|
19
|
+
expect(buildHighlightPattern("c++ (x)")?.source).toBe("c\\+\\+|\\(x\\)")
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe("findTextMatches", () => {
|
|
24
|
+
it("should find every word case-insensitively", () => {
|
|
25
|
+
const pattern = buildHighlightPattern("search config")
|
|
26
|
+
expect(pattern && findTextMatches("Search the Config, search!", pattern)).toEqual([
|
|
27
|
+
[0, 6],
|
|
28
|
+
[11, 17],
|
|
29
|
+
[19, 25],
|
|
30
|
+
])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("should prefer the longest word at a shared position", () => {
|
|
34
|
+
const pattern = buildHighlightPattern("side sidebar")
|
|
35
|
+
expect(pattern && findTextMatches("the sidebar", pattern)).toEqual([[4, 11]])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("should return no matches when nothing matches", () => {
|
|
39
|
+
const pattern = buildHighlightPattern("orama")
|
|
40
|
+
expect(pattern && findTextMatches("nothing here", pattern)).toEqual([])
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Map a `livemark/<subpath>` import onto the target copy its `exports` entry names, so
|
|
5
|
+
* escapes importing Livemark modules reach other escapes, not the package originals.
|
|
6
|
+
*/
|
|
7
|
+
export function resolveTargetExport(props: {
|
|
8
|
+
id: string
|
|
9
|
+
targetDir: string
|
|
10
|
+
exports: Record<string, string>
|
|
11
|
+
}) {
|
|
12
|
+
const queryIndex = props.id.indexOf("?")
|
|
13
|
+
const specifier = queryIndex === -1 ? props.id : props.id.slice(0, queryIndex)
|
|
14
|
+
const query = queryIndex === -1 ? "" : props.id.slice(queryIndex)
|
|
15
|
+
if (!specifier.startsWith("livemark/")) return undefined
|
|
16
|
+
|
|
17
|
+
const subpath = `./${specifier.slice("livemark/".length)}`
|
|
18
|
+
for (const [key, value] of Object.entries(props.exports)) {
|
|
19
|
+
const [prefix, suffix] = key.split("*")
|
|
20
|
+
if (suffix === undefined || !prefix || !value.startsWith("./source/")) continue
|
|
21
|
+
if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue
|
|
22
|
+
const name = subpath.slice(prefix.length, subpath.length - suffix.length)
|
|
23
|
+
return (
|
|
24
|
+
join(props.targetDir, value.slice("./source/".length).replace("*", name)) + query
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { describe, expect, it } from "vite-plus/test"
|
|
3
|
+
import packageJson from "../../package.json" with { type: "json" }
|
|
4
|
+
import { resolveTargetExport } from "./target-export.ts"
|
|
5
|
+
|
|
6
|
+
const targetDir = "/project/.livemark/.target"
|
|
7
|
+
|
|
8
|
+
function resolveId(id: string) {
|
|
9
|
+
return resolveTargetExport({ id, targetDir, exports: packageJson.exports })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("resolveTargetExport", () => {
|
|
13
|
+
it("maps each source subpath export into the target", () => {
|
|
14
|
+
expect(resolveId("livemark/components/Footer")).toBe(
|
|
15
|
+
join(targetDir, "components/Footer.tsx"),
|
|
16
|
+
)
|
|
17
|
+
expect(resolveId("livemark/elements/button")).toBe(
|
|
18
|
+
join(targetDir, "elements/button.tsx"),
|
|
19
|
+
)
|
|
20
|
+
expect(resolveId("livemark/hooks/in-view")).toBe(join(targetDir, "hooks/in-view.ts"))
|
|
21
|
+
expect(resolveId("livemark/styles/general")).toBe(
|
|
22
|
+
join(targetDir, "styles/general.css"),
|
|
23
|
+
)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("keeps the query", () => {
|
|
27
|
+
expect(resolveId("livemark/styles/general?url")).toBe(
|
|
28
|
+
`${join(targetDir, "styles/general.css")}?url`,
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("leaves other specifiers alone", () => {
|
|
33
|
+
expect(resolveId("livemark")).toBeUndefined()
|
|
34
|
+
expect(resolveId("livemark/unknown/thing")).toBeUndefined()
|
|
35
|
+
expect(resolveId("react")).toBeUndefined()
|
|
36
|
+
})
|
|
37
|
+
})
|
package/source/helpers/theme.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
const STORAGE_KEY = "livemark-theme"
|
|
2
|
+
const DARK_QUERY = "(prefers-color-scheme: dark)"
|
|
2
3
|
|
|
3
4
|
export type Theme = "light" | "dark"
|
|
5
|
+
export type ThemePref = Theme | "system"
|
|
4
6
|
|
|
5
|
-
export function
|
|
6
|
-
|
|
7
|
-
return
|
|
7
|
+
export function resolveTheme(pref: ThemePref, prefersDark: boolean): Theme {
|
|
8
|
+
if (pref === "system") return prefersDark ? "dark" : "light"
|
|
9
|
+
return pref
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export function activateTheme(theme: Theme) {
|
|
@@ -21,7 +23,39 @@ export function getCurrentTheme(): Theme {
|
|
|
21
23
|
: "light"
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
// Mirrors `buildThemeScript`: anything but an explicit light or dark follows the OS.
|
|
27
|
+
export function getThemePref(): ThemePref {
|
|
28
|
+
let stored: string | null = null
|
|
29
|
+
try {
|
|
30
|
+
stored = localStorage.getItem(STORAGE_KEY)
|
|
31
|
+
} catch {}
|
|
32
|
+
return stored === "light" || stored === "dark" ? stored : "system"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function setTheme(pref: ThemePref) {
|
|
36
|
+
applyThemePref(pref)
|
|
37
|
+
try {
|
|
38
|
+
localStorage.setItem(STORAGE_KEY, pref)
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Returns the OS listener's cleanup, so an effect StrictMode runs twice keeps one.
|
|
43
|
+
export function startTheme() {
|
|
44
|
+
const query = matchMedia(DARK_QUERY)
|
|
45
|
+
const handleChange = () => {
|
|
46
|
+
if (getThemePref() === "system") applyThemePref("system")
|
|
47
|
+
}
|
|
48
|
+
applyThemePref(getThemePref())
|
|
49
|
+
query.addEventListener("change", handleChange)
|
|
50
|
+
return () => query.removeEventListener("change", handleChange)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The pre-paint twin of `startTheme`, inlined in <head> so a dark reader never sees
|
|
54
|
+
// the light palette flash before hydration.
|
|
55
|
+
export function buildThemeScript() {
|
|
56
|
+
return `try{var t=localStorage.getItem(${JSON.stringify(STORAGE_KEY)})}catch(e){}if(t==="dark"||(t!=="light"&&matchMedia(${JSON.stringify(DARK_QUERY)}).matches))document.documentElement.classList.add("dark");`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function applyThemePref(pref: ThemePref) {
|
|
60
|
+
activateTheme(resolveTheme(pref, matchMedia(DARK_QUERY).matches))
|
|
27
61
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vite-plus/test"
|
|
2
|
+
import { buildThemeScript, resolveTheme } from "./theme.ts"
|
|
3
|
+
|
|
4
|
+
function runThemeScript(stored: string | null, prefersDark: boolean) {
|
|
5
|
+
const classes = new Set<string>()
|
|
6
|
+
vi.stubGlobal("localStorage", { getItem: () => stored })
|
|
7
|
+
vi.stubGlobal("matchMedia", () => ({ matches: prefersDark }))
|
|
8
|
+
vi.stubGlobal("document", {
|
|
9
|
+
documentElement: { classList: { add: (name: string) => classes.add(name) } },
|
|
10
|
+
})
|
|
11
|
+
new Function(buildThemeScript())()
|
|
12
|
+
vi.unstubAllGlobals()
|
|
13
|
+
return classes.has("dark") ? "dark" : "light"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("buildThemeScript", () => {
|
|
17
|
+
it.each([
|
|
18
|
+
["light", false],
|
|
19
|
+
["light", true],
|
|
20
|
+
["dark", false],
|
|
21
|
+
["dark", true],
|
|
22
|
+
["system", false],
|
|
23
|
+
["system", true],
|
|
24
|
+
] as const)("paints what resolveTheme(%s, prefersDark %s) does", (pref, dark) => {
|
|
25
|
+
expect(runThemeScript(pref, dark)).toBe(resolveTheme(pref, dark))
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it.each([null, "", "auto"])("follows the OS for a stored %j", stored => {
|
|
29
|
+
expect(runThemeScript(stored, true)).toBe("dark")
|
|
30
|
+
expect(runThemeScript(stored, false)).toBe("light")
|
|
31
|
+
})
|
|
32
|
+
})
|