livemark 0.0.0-dev → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/build/actions/changelog/build.d.ts +14 -0
- package/build/actions/changelog/build.js +158 -0
- package/build/actions/changelog/build.unit.d.ts +1 -0
- package/build/actions/changelog/build.unit.js +66 -0
- package/build/actions/config/define.unit.js +11 -16
- package/build/actions/config/load.js +6 -3
- package/build/actions/config/load.unit.js +3 -3
- package/build/commands/build.js +4 -3
- package/build/commands/escape.d.ts +9 -0
- package/build/commands/escape.js +56 -0
- package/build/docs/markdown/includes/example.d.ts +53 -4
- package/build/docs/markdown/includes/example.js +2 -2
- package/build/livemark.config.d.ts +53 -4
- package/build/livemark.config.js +22 -2
- package/build/models/config.d.ts +315 -8
- package/build/models/config.js +25 -5
- package/build/models/link.d.ts +13 -0
- package/build/models/link.js +9 -0
- package/build/models/patch.d.ts +21 -0
- package/build/models/patch.js +7 -0
- package/build/models/section.d.ts +75 -0
- package/build/models/section.js +26 -0
- package/build/models/theme.d.ts +72 -0
- package/build/models/theme.js +73 -0
- package/build/program.js +4 -2
- package/build/vite.config.d.ts +1 -1
- package/build/website/assets/logo.svg +117 -0
- package/build/website/components/Article.tsx +172 -0
- package/build/website/components/BackToTop.tsx +32 -0
- package/build/website/components/Banner.tsx +2 -2
- package/build/website/components/BlogIndex.tsx +121 -0
- package/build/website/components/BlogSidebar.tsx +162 -0
- package/build/website/components/Breadcrumbs.tsx +5 -2
- package/build/website/components/Card.tsx +48 -0
- package/build/website/components/Cards.tsx +0 -58
- package/build/website/components/ChangelogIndex.tsx +111 -0
- package/build/website/components/ChangelogSidebar.tsx +120 -0
- package/build/website/components/CodeBlock.tsx +57 -40
- package/build/website/components/Column.tsx +4 -0
- package/build/website/components/Columns.tsx +0 -5
- package/build/website/components/Footer.tsx +12 -5
- package/build/website/components/InlineIcon.tsx +2 -13
- package/build/website/components/Layout.tsx +155 -185
- package/build/website/components/Mermaid.tsx +14 -33
- package/build/website/components/MermaidRenderer.tsx +37 -0
- package/build/website/components/MiniSidebar.tsx +49 -0
- package/build/website/components/PageToolbar.tsx +21 -11
- package/build/website/components/PrevNext.tsx +20 -12
- package/build/website/components/Search.tsx +28 -0
- package/build/website/components/SearchDialog.tsx +10 -10
- package/build/website/components/Sidebar.tsx +160 -0
- package/build/website/components/SidebarLinks.tsx +70 -0
- package/build/website/components/SidebarSections.tsx +71 -0
- package/build/website/components/SiteTitle.tsx +13 -8
- package/build/website/components/TagIndex.tsx +120 -0
- package/build/website/components/Theme.tsx +0 -1
- package/build/website/components/Toc.tsx +128 -1
- package/build/website/components/ZoomImage.tsx +15 -20
- package/build/website/content/article.ts +78 -0
- package/build/website/content/tag.ts +29 -0
- package/build/website/content-collections.ts +53 -26
- package/build/website/elements/sidebar.tsx +4 -4
- package/build/website/env.d.ts +4 -0
- package/build/website/helpers/article-icon.ts +2 -0
- package/build/website/helpers/article.ts +90 -0
- package/build/website/helpers/changelog-source.ts +16 -0
- package/build/website/helpers/changelog-source.unit.ts +38 -0
- package/build/website/helpers/dynamic-icon-resolver.tsx +19 -0
- package/build/website/helpers/dynamic-icon.tsx +12 -0
- package/build/website/helpers/favicon.ts +7 -0
- package/build/website/helpers/model.ts +4 -0
- package/build/website/helpers/resolve-asset-path.ts +9 -0
- package/build/website/helpers/section.ts +50 -0
- package/build/website/helpers/section.unit.ts +71 -0
- package/build/website/helpers/toc-context.ts +1 -1
- package/build/website/helpers/toc.ts +1 -6
- package/build/website/models/article.ts +48 -33
- package/build/website/models/toc.ts +5 -0
- package/build/website/plugins/rehype-task-list-a11y.ts +33 -0
- package/build/website/plugins/remark-ansi.ts +1 -0
- package/build/website/plugins/remark-image.ts +22 -13
- package/build/website/plugins/vite-livemark.ts +91 -0
- package/build/website/routeTree.gen.ts +9 -9
- package/build/website/routes/$.tsx +74 -131
- package/build/website/routes/__root.tsx +36 -23
- package/build/website/styles/general.css +26 -210
- package/build/website/styles/markdown.css +214 -0
- package/build/website/vite.config.ts +38 -3
- package/package.json +25 -16
- package/build/website/globals.d.ts +0 -8
- package/build/website/helpers/articles.ts +0 -24
- package/build/website/public/logo.svg +0 -72
- package/build/website/routes/index.tsx +0 -13
|
@@ -1,30 +1,25 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useEffect, useState, type ComponentProps } from "react"
|
|
2
2
|
import Zoom from "react-medium-image-zoom"
|
|
3
3
|
import "react-medium-image-zoom/dist/styles.css"
|
|
4
4
|
|
|
5
5
|
/** Image component with click-to-zoom and light/dark theme variant support */
|
|
6
6
|
export function ZoomImage(props: ComponentProps<"img">) {
|
|
7
|
-
const
|
|
7
|
+
const [mounted, setMounted] = useState(false)
|
|
8
|
+
useEffect(() => setMounted(true), [])
|
|
8
9
|
|
|
10
|
+
const src = props.src ?? ""
|
|
11
|
+
let imgProps = props
|
|
9
12
|
if (src.endsWith("#light")) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (src.endsWith("#dark")) {
|
|
18
|
-
return (
|
|
19
|
-
<Zoom>
|
|
20
|
-
<img {...props} src={src.slice(0, -5)} className="hidden dark:block" />
|
|
21
|
-
</Zoom>
|
|
22
|
-
)
|
|
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
|
+
}
|
|
23
20
|
}
|
|
24
21
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
</Zoom>
|
|
29
|
-
)
|
|
22
|
+
const img = <img {...imgProps} alt={imgProps.alt ?? ""} />
|
|
23
|
+
if (!mounted) return img
|
|
24
|
+
return <Zoom>{img}</Zoom>
|
|
30
25
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { allArticles } from "content-collections"
|
|
2
|
+
import type { ArticleGroup } from "../models/article.ts"
|
|
3
|
+
import {
|
|
4
|
+
buildArticleTree,
|
|
5
|
+
flattenArticleTree,
|
|
6
|
+
groupArticleTree,
|
|
7
|
+
} from "../helpers/article.ts"
|
|
8
|
+
import { matchSection, partitionBySection } from "../helpers/section.ts"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Articles sorted by frontmatter `order`:
|
|
12
|
+
* positive values first (ascending), then unordered, then negative values
|
|
13
|
+
* (so `order: -1` lands at the very end, `-2` just before it, and so on).
|
|
14
|
+
*/
|
|
15
|
+
export const sortedArticles = [...allArticles].sort(
|
|
16
|
+
(a, b) => orderKey(a.order) - orderKey(b.order),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
/** The article whose path is `/`, if any (rendered in place at the root) */
|
|
20
|
+
export const homeArticle = sortedArticles.find(a => a.path === "/")
|
|
21
|
+
|
|
22
|
+
/** Sidebar sections derived from the path tree, partitioned by `group` */
|
|
23
|
+
export const articleGroups = groupArticleTree(
|
|
24
|
+
buildArticleTree(sortedArticles),
|
|
25
|
+
sortedArticles,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
/** Articles in depth-first order across all sections (matches sidebar walk) */
|
|
29
|
+
export const flatArticles = articleGroups.flatMap(group =>
|
|
30
|
+
flattenArticleTree(group.nodes),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
/** The first article in sidebar order, used as the default landing page */
|
|
34
|
+
export const firstArticle = sortedArticles.find(a => a.path === flatArticles[0])
|
|
35
|
+
|
|
36
|
+
function orderKey(order?: number) {
|
|
37
|
+
if (order === undefined) return Number.MAX_SAFE_INTEGER / 2
|
|
38
|
+
if (order < 0) return Number.MAX_SAFE_INTEGER + order
|
|
39
|
+
return order
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const configSections = import.meta.env.CONFIG.sections
|
|
43
|
+
|
|
44
|
+
/** Per-section article groups, keyed by section prefix */
|
|
45
|
+
export const sectionArticleGroups = new Map<string, ArticleGroup[]>()
|
|
46
|
+
|
|
47
|
+
/** Per-section flat article lists, keyed by section prefix */
|
|
48
|
+
export const sectionFlatArticles = new Map<string, string[]>()
|
|
49
|
+
|
|
50
|
+
/** Per-section first article path, keyed by section prefix */
|
|
51
|
+
export const sectionFirstArticle = new Map<string, string | undefined>()
|
|
52
|
+
|
|
53
|
+
if (configSections?.length) {
|
|
54
|
+
const sectionByPrefix = new Map(configSections.map(s => [s.prefix, s]))
|
|
55
|
+
const buckets = partitionBySection(sortedArticles, configSections)
|
|
56
|
+
for (const [key, bucket] of buckets) {
|
|
57
|
+
const section = sectionByPrefix.get(key)
|
|
58
|
+
const sorted =
|
|
59
|
+
section?.type === "blog" || section?.type === "changelog"
|
|
60
|
+
? [...bucket].sort(
|
|
61
|
+
(a, b) =>
|
|
62
|
+
new Date(b.date ?? 0).getTime() - new Date(a.date ?? 0).getTime(),
|
|
63
|
+
)
|
|
64
|
+
: bucket
|
|
65
|
+
const tree = buildArticleTree(sorted)
|
|
66
|
+
const groups = groupArticleTree(tree, sorted)
|
|
67
|
+
const flat = groups.flatMap(g => flattenArticleTree(g.nodes))
|
|
68
|
+
sectionArticleGroups.set(key, groups)
|
|
69
|
+
sectionFlatArticles.set(key, flat)
|
|
70
|
+
sectionFirstArticle.set(key, flat[0])
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Find which config section an article path belongs to */
|
|
75
|
+
export function currentSection(path: string) {
|
|
76
|
+
if (!configSections?.length) return undefined
|
|
77
|
+
return matchSection(path, configSections)
|
|
78
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { sectionFlatArticles, sortedArticles } from "./article.ts"
|
|
2
|
+
|
|
3
|
+
const configSections = import.meta.env.CONFIG.sections
|
|
4
|
+
|
|
5
|
+
/** Per-section tag → article paths mapping. Articles sorted by date descending. */
|
|
6
|
+
export const sectionTags = new Map<string, Map<string, string[]>>()
|
|
7
|
+
|
|
8
|
+
if (configSections?.length) {
|
|
9
|
+
for (const section of configSections) {
|
|
10
|
+
if (section.type !== "blog") continue
|
|
11
|
+
const flat = sectionFlatArticles.get(section.prefix) ?? []
|
|
12
|
+
const tagMap = new Map<string, string[]>()
|
|
13
|
+
for (const path of flat) {
|
|
14
|
+
const article = sortedArticles.find(a => a.path === path)
|
|
15
|
+
if (!article?.tags) continue
|
|
16
|
+
for (const tag of article.tags) {
|
|
17
|
+
const list = tagMap.get(tag)
|
|
18
|
+
if (list) {
|
|
19
|
+
list.push(path)
|
|
20
|
+
} else {
|
|
21
|
+
tagMap.set(tag, [path])
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (tagMap.size > 0) {
|
|
26
|
+
sectionTags.set(section.prefix, tagMap)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -11,8 +11,9 @@ import remarkDirective from "remark-directive"
|
|
|
11
11
|
import remarkGemoji from "remark-gemoji"
|
|
12
12
|
import remarkGfm from "remark-gfm"
|
|
13
13
|
import remarkMath from "remark-math"
|
|
14
|
-
import { z } from "zod"
|
|
15
14
|
import { loadConfig } from "../actions/config/load.ts"
|
|
15
|
+
import { buildChangelog, cacheIncludeGlob } from "../actions/changelog/build.ts"
|
|
16
|
+
import { Article } from "./models/article.ts"
|
|
16
17
|
import { pickDefaultIcon } from "./helpers/article-icon.ts"
|
|
17
18
|
import slugify from "@sindresorhus/slugify"
|
|
18
19
|
import remarkCustomHeadingId from "./plugins/remark-custom-heading-id.ts"
|
|
@@ -41,19 +42,34 @@ import {
|
|
|
41
42
|
transformerNotations,
|
|
42
43
|
} from "./plugins/shiki-line-highlight.ts"
|
|
43
44
|
import { rehypeInlineCode } from "./plugins/rehype-inline-code.ts"
|
|
45
|
+
import { rehypeTaskListA11y } from "./plugins/rehype-task-list-a11y.ts"
|
|
44
46
|
import { transformerIcon } from "./plugins/shiki-icon.ts"
|
|
47
|
+
import { resolveAssetPath } from "./helpers/resolve-asset-path.ts"
|
|
45
48
|
import { resolveIncludes } from "./helpers/resolve-includes.ts"
|
|
46
49
|
import { extractSearchText } from "./helpers/search-text.ts"
|
|
47
50
|
import { extractToc } from "./helpers/toc.ts"
|
|
48
51
|
|
|
49
52
|
const config = await loadConfig()
|
|
50
53
|
|
|
54
|
+
const changelogSections =
|
|
55
|
+
config.sections?.filter(s => s.type === "changelog") ?? []
|
|
56
|
+
for (const section of changelogSections) {
|
|
57
|
+
await buildChangelog(section, config)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const configInclude = Array.isArray(config.include)
|
|
61
|
+
? config.include
|
|
62
|
+
: [config.include]
|
|
63
|
+
const include = changelogSections.length
|
|
64
|
+
? [...configInclude, cacheIncludeGlob()]
|
|
65
|
+
: configInclude
|
|
66
|
+
|
|
51
67
|
// content-collections compiles this file to .content-collections/cache/;
|
|
52
68
|
// going up 2 levels reconstructs the baseDirectory it uses for path resolution
|
|
53
69
|
const baseDir = resolve(import.meta.dirname, "../..")
|
|
54
70
|
const directory = relative(baseDir, config.root) || "."
|
|
55
71
|
|
|
56
|
-
function
|
|
72
|
+
function toPath(filePath: string) {
|
|
57
73
|
const name = basename(filePath).replace(/\.\w+$/, "")
|
|
58
74
|
return slugify(name)
|
|
59
75
|
}
|
|
@@ -84,25 +100,20 @@ function extractTitle(content: string, path: string) {
|
|
|
84
100
|
const articles = defineCollection({
|
|
85
101
|
name: "articles",
|
|
86
102
|
directory,
|
|
87
|
-
include
|
|
88
|
-
exclude: config.
|
|
89
|
-
schema:
|
|
90
|
-
content: z.string(),
|
|
91
|
-
title: z.string().optional(),
|
|
92
|
-
description: z.string().optional(),
|
|
93
|
-
icon: z.string().optional(),
|
|
94
|
-
order: z.number().optional(),
|
|
95
|
-
pathname: z.string().optional(),
|
|
96
|
-
}),
|
|
103
|
+
include,
|
|
104
|
+
exclude: config.exclude,
|
|
105
|
+
schema: Article,
|
|
97
106
|
transform: async (document, context) => {
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
const
|
|
107
|
+
const patch = config.patches?.find(p => p.file === document._meta.filePath)
|
|
108
|
+
const doc = patch ? { ...document, ...patch.article } : document
|
|
109
|
+
const filePath = resolve(config.root, doc._meta.filePath)
|
|
110
|
+
const content = resolveIncludes(doc.content, filePath)
|
|
111
|
+
const title = doc.title ?? extractTitle(content, doc._meta.path)
|
|
112
|
+
const icon = doc.icon ?? pickDefaultIcon(doc._meta.path)
|
|
113
|
+
const path = doc.path ?? toPath(doc._meta.filePath)
|
|
103
114
|
const mdx = await compileMDX(
|
|
104
115
|
context,
|
|
105
|
-
{ ...
|
|
116
|
+
{ ...doc, content },
|
|
106
117
|
{
|
|
107
118
|
remarkPlugins: [
|
|
108
119
|
remarkGfm,
|
|
@@ -125,7 +136,7 @@ const articles = defineCollection({
|
|
|
125
136
|
[
|
|
126
137
|
remarkImage,
|
|
127
138
|
{
|
|
128
|
-
filePath: resolve(config.root,
|
|
139
|
+
filePath: resolve(config.root, doc._meta.filePath),
|
|
129
140
|
root: config.root,
|
|
130
141
|
},
|
|
131
142
|
],
|
|
@@ -142,7 +153,18 @@ const articles = defineCollection({
|
|
|
142
153
|
[
|
|
143
154
|
rehypeShiki,
|
|
144
155
|
{
|
|
145
|
-
themes: {
|
|
156
|
+
themes: {
|
|
157
|
+
light: config.codeThemeLight,
|
|
158
|
+
dark: config.codeThemeDark,
|
|
159
|
+
},
|
|
160
|
+
// Darken catppuccin-latte's green (the colour used for strings and
|
|
161
|
+
// some comments) — the theme default #40A02B lands at 3.2:1 on the
|
|
162
|
+
// code-block background, below the WCAG AA 4.5:1 threshold.
|
|
163
|
+
colorReplacements: {
|
|
164
|
+
"catppuccin-latte": {
|
|
165
|
+
"#40a02b": "#2a7e16",
|
|
166
|
+
},
|
|
167
|
+
},
|
|
146
168
|
transformers: [
|
|
147
169
|
transformerTwoslash({ explicitTrigger: true }),
|
|
148
170
|
transformerIcon(),
|
|
@@ -155,20 +177,25 @@ const articles = defineCollection({
|
|
|
155
177
|
rehypeInlineCode,
|
|
156
178
|
rehypeSlug,
|
|
157
179
|
rehypeKatex,
|
|
180
|
+
rehypeTaskListA11y,
|
|
158
181
|
],
|
|
159
182
|
},
|
|
160
183
|
)
|
|
161
|
-
const
|
|
184
|
+
const tocItems = extractToc(content)
|
|
162
185
|
const searchText = extractSearchText(content)
|
|
163
|
-
const lastUpdated = getLastUpdated(
|
|
186
|
+
const lastUpdated = getLastUpdated(doc._meta.filePath)
|
|
187
|
+
const image = doc.image
|
|
188
|
+
? resolveAssetPath(doc.image, filePath, config.root)
|
|
189
|
+
: undefined
|
|
164
190
|
return {
|
|
165
|
-
...
|
|
166
|
-
|
|
191
|
+
...doc,
|
|
192
|
+
file: doc._meta.filePath,
|
|
167
193
|
lastUpdated,
|
|
194
|
+
image,
|
|
168
195
|
title,
|
|
169
196
|
icon,
|
|
170
|
-
|
|
171
|
-
|
|
197
|
+
path,
|
|
198
|
+
tocItems,
|
|
172
199
|
searchText,
|
|
173
200
|
mdx,
|
|
174
201
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { mergeProps } from "@base-ui/react/merge-props"
|
|
3
3
|
import { useRender } from "@base-ui/react/use-render"
|
|
4
4
|
import { cva, type VariantProps } from "class-variance-authority"
|
|
5
|
-
import {
|
|
5
|
+
import { Menu } from "lucide-react"
|
|
6
6
|
import * as React from "react"
|
|
7
7
|
import { cn } from "../helpers/style.ts"
|
|
8
8
|
import { useIsMobile } from "../hooks/media.ts"
|
|
@@ -265,7 +265,7 @@ function SidebarTrigger({
|
|
|
265
265
|
}}
|
|
266
266
|
{...props}
|
|
267
267
|
>
|
|
268
|
-
<
|
|
268
|
+
<Menu />
|
|
269
269
|
<span className="sr-only">Toggle Sidebar</span>
|
|
270
270
|
</Button>
|
|
271
271
|
)
|
|
@@ -469,7 +469,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
|
|
469
469
|
}
|
|
470
470
|
|
|
471
471
|
const sidebarMenuButtonVariants = cva(
|
|
472
|
-
"ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-primary/5 data-active:border data-active:border-primary/20 data-active:text-primary data-active:[&_svg]:stroke-[2.5] data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium peer/menu-button flex w-full items-center overflow-hidden outline-hidden group/menu-button disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&_svg]:size-4 [&_svg]:shrink-0",
|
|
472
|
+
"ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-primary/2 dark:data-active:bg-primary/5 data-active:border data-active:border-primary/20 data-active:text-primary data-active:[&_svg]:stroke-[2.5] data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium peer/menu-button flex w-full items-center overflow-hidden outline-hidden group/menu-button disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&_svg]:size-4 [&_svg]:shrink-0",
|
|
473
473
|
{
|
|
474
474
|
variants: {
|
|
475
475
|
variant: {
|
|
@@ -673,7 +673,7 @@ function SidebarMenuSubButton({
|
|
|
673
673
|
props: mergeProps<"a">(
|
|
674
674
|
{
|
|
675
675
|
className: cn(
|
|
676
|
-
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-primary/5 data-active:border data-active:border-primary/20 data-active:text-primary data-active:font-medium h-7 gap-2 rounded-md px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0",
|
|
676
|
+
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-primary/2 dark:data-active:bg-primary/5 data-active:border data-active:border-primary/20 data-active:text-primary data-active:font-medium h-7 gap-2 rounded-md px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0",
|
|
677
677
|
className,
|
|
678
678
|
),
|
|
679
679
|
},
|
package/build/website/env.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
Rocket,
|
|
17
17
|
Server,
|
|
18
18
|
Shield,
|
|
19
|
+
SlidersHorizontal,
|
|
19
20
|
Terminal,
|
|
20
21
|
Webhook,
|
|
21
22
|
Zap,
|
|
@@ -42,6 +43,7 @@ export const articleIcons: Record<
|
|
|
42
43
|
rocket: Rocket,
|
|
43
44
|
server: Server,
|
|
44
45
|
shield: Shield,
|
|
46
|
+
"sliders-horizontal": SlidersHorizontal,
|
|
45
47
|
terminal: Terminal,
|
|
46
48
|
webhook: Webhook,
|
|
47
49
|
zap: Zap,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ArticleGroup, ArticleNode } from "../models/article.ts"
|
|
2
|
+
|
|
3
|
+
/** Build a tree from a flat sorted list of articles based on path hierarchy */
|
|
4
|
+
export function buildArticleTree(
|
|
5
|
+
articles: {
|
|
6
|
+
path: string
|
|
7
|
+
title: string
|
|
8
|
+
label?: string
|
|
9
|
+
icon: string
|
|
10
|
+
}[],
|
|
11
|
+
) {
|
|
12
|
+
const nodeMap = new Map<string, ArticleNode>()
|
|
13
|
+
|
|
14
|
+
for (const article of articles) {
|
|
15
|
+
nodeMap.set(article.path, {
|
|
16
|
+
path: article.path,
|
|
17
|
+
title: article.title,
|
|
18
|
+
label: article.label,
|
|
19
|
+
icon: article.icon,
|
|
20
|
+
children: [],
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const roots: ArticleNode[] = []
|
|
25
|
+
|
|
26
|
+
for (const article of articles) {
|
|
27
|
+
const node = nodeMap.get(article.path)
|
|
28
|
+
if (!node) continue
|
|
29
|
+
const trimmed = article.path.replace(/\/$/, "")
|
|
30
|
+
const lastSlash = trimmed.lastIndexOf("/")
|
|
31
|
+
const parentPath = lastSlash > 0 ? `${trimmed.slice(0, lastSlash)}/` : ""
|
|
32
|
+
const parent = parentPath ? nodeMap.get(parentPath) : undefined
|
|
33
|
+
|
|
34
|
+
if (parent) {
|
|
35
|
+
parent.children.push(node)
|
|
36
|
+
} else {
|
|
37
|
+
roots.push(node)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return roots
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Partition root nodes into sidebar sections.
|
|
46
|
+
*
|
|
47
|
+
* Walks root nodes in sort order. A root whose article has a `group` field
|
|
48
|
+
* opens a new section; subsequent roots without `group` append to the current
|
|
49
|
+
* section. Roots before the first `group` field land in a single leading
|
|
50
|
+
* section with `name: undefined` (rendered without a label).
|
|
51
|
+
*
|
|
52
|
+
* `group` on non-root articles is ignored — only roots gate section boundaries.
|
|
53
|
+
*/
|
|
54
|
+
export function groupArticleTree(
|
|
55
|
+
tree: ArticleNode[],
|
|
56
|
+
articles: { path: string; group?: string }[],
|
|
57
|
+
): ArticleGroup[] {
|
|
58
|
+
const groupByPathname = new Map<string, string | undefined>()
|
|
59
|
+
for (const article of articles) {
|
|
60
|
+
groupByPathname.set(article.path, article.group)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sections: ArticleGroup[] = []
|
|
64
|
+
let current: ArticleGroup | undefined
|
|
65
|
+
|
|
66
|
+
for (const node of tree) {
|
|
67
|
+
const group = groupByPathname.get(node.path)
|
|
68
|
+
if (group) {
|
|
69
|
+
current = { name: group, nodes: [node] }
|
|
70
|
+
sections.push(current)
|
|
71
|
+
} else if (current) {
|
|
72
|
+
current.nodes.push(node)
|
|
73
|
+
} else {
|
|
74
|
+
current = { name: undefined, nodes: [node] }
|
|
75
|
+
sections.push(current)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return sections
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Depth-first flatten of an article tree into a list of paths */
|
|
83
|
+
export function flattenArticleTree(nodes: ArticleNode[]) {
|
|
84
|
+
const result: string[] = []
|
|
85
|
+
for (const node of nodes) {
|
|
86
|
+
result.push(node.path)
|
|
87
|
+
result.push(...flattenArticleTree(node.children))
|
|
88
|
+
}
|
|
89
|
+
return result
|
|
90
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Returns true if a changelog source string is a GitHub repo URL */
|
|
2
|
+
export function isGitHubUrl(source: string) {
|
|
3
|
+
return /^(https?:\/\/)?(www\.)?github\.com\//.test(source)
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Extract `{ owner, repo }` from a GitHub URL. Returns undefined if unparseable */
|
|
7
|
+
export function parseGitHubRepo(source: string) {
|
|
8
|
+
const match = source.match(
|
|
9
|
+
/^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/]+)\/([^/#?]+)/,
|
|
10
|
+
)
|
|
11
|
+
if (!match) return undefined
|
|
12
|
+
const owner = match[1]
|
|
13
|
+
const repo = match[2]?.replace(/\.git$/, "")
|
|
14
|
+
if (!owner || !repo) return undefined
|
|
15
|
+
return { owner, repo }
|
|
16
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test"
|
|
2
|
+
import { isGitHubUrl, parseGitHubRepo } from "./changelog-source.ts"
|
|
3
|
+
|
|
4
|
+
describe("isGitHubUrl", () => {
|
|
5
|
+
it("recognizes https github URLs", () => {
|
|
6
|
+
expect(isGitHubUrl("https://github.com/foo/bar")).toBe(true)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it("recognizes bare github.com URLs", () => {
|
|
10
|
+
expect(isGitHubUrl("github.com/foo/bar")).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("rejects local paths", () => {
|
|
14
|
+
expect(isGitHubUrl("CHANGELOG.md")).toBe(false)
|
|
15
|
+
expect(isGitHubUrl("./CHANGELOG.md")).toBe(false)
|
|
16
|
+
expect(isGitHubUrl("docs/CHANGELOG.md")).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe("parseGitHubRepo", () => {
|
|
21
|
+
it("extracts owner and repo from https URL", () => {
|
|
22
|
+
expect(parseGitHubRepo("https://github.com/datisthq/livemark")).toEqual({
|
|
23
|
+
owner: "datisthq",
|
|
24
|
+
repo: "livemark",
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("strips .git suffix", () => {
|
|
29
|
+
expect(parseGitHubRepo("https://github.com/foo/bar.git")).toEqual({
|
|
30
|
+
owner: "foo",
|
|
31
|
+
repo: "bar",
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("returns undefined for non-github input", () => {
|
|
36
|
+
expect(parseGitHubRepo("CHANGELOG.md")).toBeUndefined()
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { icons } from "lucide-react"
|
|
2
|
+
|
|
3
|
+
function toPascalCase(name: string) {
|
|
4
|
+
return name
|
|
5
|
+
.split("-")
|
|
6
|
+
.map(s => s.charAt(0).toUpperCase() + s.slice(1))
|
|
7
|
+
.join("")
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Resolves a Lucide icon by kebab-case name from the full registry */
|
|
11
|
+
export default function DynamicIconResolver(props: {
|
|
12
|
+
name: string
|
|
13
|
+
className?: string
|
|
14
|
+
}) {
|
|
15
|
+
const key = toPascalCase(props.name)
|
|
16
|
+
if (!(key in icons)) return null
|
|
17
|
+
const Icon = icons[key as keyof typeof icons]
|
|
18
|
+
return <Icon className={props.className} />
|
|
19
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Suspense, lazy } from "react"
|
|
2
|
+
|
|
3
|
+
const Resolver = lazy(() => import("./dynamic-icon-resolver.tsx"))
|
|
4
|
+
|
|
5
|
+
/** Lazily loads and renders a Lucide icon by kebab-case name */
|
|
6
|
+
export function DynamicIcon(props: { name: string; className?: string }) {
|
|
7
|
+
return (
|
|
8
|
+
<Suspense>
|
|
9
|
+
<Resolver {...props} />
|
|
10
|
+
</Suspense>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Derive the MIME type for a favicon from its file extension */
|
|
2
|
+
export function faviconType(href: string) {
|
|
3
|
+
if (href.endsWith(".svg")) return "image/svg+xml"
|
|
4
|
+
if (href.endsWith(".png")) return "image/png"
|
|
5
|
+
if (href.endsWith(".ico")) return "image/x-icon"
|
|
6
|
+
return undefined
|
|
7
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { dirname, relative, resolve } from "node:path"
|
|
2
|
+
|
|
3
|
+
/** Resolve a relative asset path to an absolute URL serveable by Vite */
|
|
4
|
+
export function resolveAssetPath(url: string, filePath: string, root: string) {
|
|
5
|
+
if (url.startsWith("http") || url.startsWith("/") || url.startsWith("data:"))
|
|
6
|
+
return url
|
|
7
|
+
const absolute = resolve(dirname(filePath), url)
|
|
8
|
+
return "/" + relative(root, absolute)
|
|
9
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Section } from "../../models/section.ts"
|
|
2
|
+
|
|
3
|
+
const DEFAULT_ICONS: Record<Section["type"], string> = {
|
|
4
|
+
article: "book-open",
|
|
5
|
+
blog: "rss",
|
|
6
|
+
changelog: "history",
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Return the section's icon, falling back to a type-based default */
|
|
10
|
+
export function sectionIcon(section: Pick<Section, "icon" | "type">) {
|
|
11
|
+
return section.icon ?? DEFAULT_ICONS[section.type]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Find the section whose prefix is the longest prefix of the article path */
|
|
15
|
+
export function matchSection<S extends { prefix: string }>(
|
|
16
|
+
articlePath: string,
|
|
17
|
+
sections: S[],
|
|
18
|
+
): S | undefined {
|
|
19
|
+
let best: S | undefined
|
|
20
|
+
let bestLen = 0
|
|
21
|
+
for (const section of sections) {
|
|
22
|
+
if (
|
|
23
|
+
articlePath.startsWith(section.prefix) &&
|
|
24
|
+
section.prefix.length > bestLen
|
|
25
|
+
) {
|
|
26
|
+
best = section
|
|
27
|
+
bestLen = section.prefix.length
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return best
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Split articles into per-section buckets. Unmatched articles go to "__default__" */
|
|
34
|
+
export function partitionBySection<T extends { path: string }>(
|
|
35
|
+
articles: T[],
|
|
36
|
+
sections: { prefix: string }[],
|
|
37
|
+
): Map<string, T[]> {
|
|
38
|
+
const buckets = new Map<string, T[]>()
|
|
39
|
+
for (const article of articles) {
|
|
40
|
+
const section = matchSection(article.path, sections)
|
|
41
|
+
const key = section?.prefix ?? "__default__"
|
|
42
|
+
const bucket = buckets.get(key)
|
|
43
|
+
if (bucket) {
|
|
44
|
+
bucket.push(article)
|
|
45
|
+
} else {
|
|
46
|
+
buckets.set(key, [article])
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return buckets
|
|
50
|
+
}
|