livemark 0.0.0-dev → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +5 -1
  2. package/build/actions/changelog/build.d.ts +14 -0
  3. package/build/actions/changelog/build.js +158 -0
  4. package/build/actions/changelog/build.unit.d.ts +1 -0
  5. package/build/actions/changelog/build.unit.js +66 -0
  6. package/build/actions/config/define.unit.js +11 -16
  7. package/build/actions/config/load.js +6 -3
  8. package/build/actions/config/load.unit.js +3 -3
  9. package/build/commands/build.js +4 -3
  10. package/build/commands/escape.d.ts +9 -0
  11. package/build/commands/escape.js +56 -0
  12. package/build/docs/markdown/includes/example.d.ts +53 -4
  13. package/build/docs/markdown/includes/example.js +2 -2
  14. package/build/livemark.config.d.ts +53 -4
  15. package/build/livemark.config.js +22 -2
  16. package/build/models/config.d.ts +315 -8
  17. package/build/models/config.js +25 -5
  18. package/build/models/link.d.ts +13 -0
  19. package/build/models/link.js +9 -0
  20. package/build/models/patch.d.ts +21 -0
  21. package/build/models/patch.js +7 -0
  22. package/build/models/section.d.ts +75 -0
  23. package/build/models/section.js +26 -0
  24. package/build/models/theme.d.ts +72 -0
  25. package/build/models/theme.js +73 -0
  26. package/build/program.js +4 -2
  27. package/build/vite.config.d.ts +1 -1
  28. package/build/website/assets/logo.svg +117 -0
  29. package/build/website/components/Article.tsx +172 -0
  30. package/build/website/components/BackToTop.tsx +32 -0
  31. package/build/website/components/Banner.tsx +2 -2
  32. package/build/website/components/BlogIndex.tsx +121 -0
  33. package/build/website/components/BlogSidebar.tsx +162 -0
  34. package/build/website/components/Breadcrumbs.tsx +5 -2
  35. package/build/website/components/Card.tsx +48 -0
  36. package/build/website/components/Cards.tsx +0 -58
  37. package/build/website/components/ChangelogIndex.tsx +111 -0
  38. package/build/website/components/ChangelogSidebar.tsx +120 -0
  39. package/build/website/components/CodeBlock.tsx +57 -40
  40. package/build/website/components/Column.tsx +4 -0
  41. package/build/website/components/Columns.tsx +0 -5
  42. package/build/website/components/Footer.tsx +12 -5
  43. package/build/website/components/InlineIcon.tsx +2 -13
  44. package/build/website/components/Layout.tsx +155 -185
  45. package/build/website/components/Mermaid.tsx +14 -33
  46. package/build/website/components/MermaidRenderer.tsx +37 -0
  47. package/build/website/components/MiniSidebar.tsx +49 -0
  48. package/build/website/components/PageToolbar.tsx +21 -11
  49. package/build/website/components/PrevNext.tsx +20 -12
  50. package/build/website/components/Search.tsx +28 -0
  51. package/build/website/components/SearchDialog.tsx +10 -10
  52. package/build/website/components/Sidebar.tsx +160 -0
  53. package/build/website/components/SidebarLinks.tsx +70 -0
  54. package/build/website/components/SidebarSections.tsx +71 -0
  55. package/build/website/components/SiteTitle.tsx +13 -8
  56. package/build/website/components/TagIndex.tsx +120 -0
  57. package/build/website/components/Theme.tsx +0 -1
  58. package/build/website/components/Toc.tsx +128 -1
  59. package/build/website/components/ZoomImage.tsx +15 -20
  60. package/build/website/content/article.ts +78 -0
  61. package/build/website/content/tag.ts +29 -0
  62. package/build/website/content-collections.ts +53 -26
  63. package/build/website/elements/sidebar.tsx +4 -4
  64. package/build/website/env.d.ts +4 -0
  65. package/build/website/helpers/article-icon.ts +2 -0
  66. package/build/website/helpers/article.ts +90 -0
  67. package/build/website/helpers/changelog-source.ts +16 -0
  68. package/build/website/helpers/changelog-source.unit.ts +38 -0
  69. package/build/website/helpers/dynamic-icon-resolver.tsx +19 -0
  70. package/build/website/helpers/dynamic-icon.tsx +12 -0
  71. package/build/website/helpers/favicon.ts +7 -0
  72. package/build/website/helpers/model.ts +4 -0
  73. package/build/website/helpers/resolve-asset-path.ts +9 -0
  74. package/build/website/helpers/section.ts +50 -0
  75. package/build/website/helpers/section.unit.ts +71 -0
  76. package/build/website/helpers/toc-context.ts +1 -1
  77. package/build/website/helpers/toc.ts +1 -6
  78. package/build/website/models/article.ts +48 -33
  79. package/build/website/models/toc.ts +5 -0
  80. package/build/website/plugins/rehype-task-list-a11y.ts +33 -0
  81. package/build/website/plugins/remark-ansi.ts +1 -0
  82. package/build/website/plugins/remark-image.ts +22 -13
  83. package/build/website/plugins/vite-livemark.ts +91 -0
  84. package/build/website/routeTree.gen.ts +9 -9
  85. package/build/website/routes/$.tsx +74 -131
  86. package/build/website/routes/__root.tsx +36 -23
  87. package/build/website/styles/general.css +26 -210
  88. package/build/website/styles/markdown.css +214 -0
  89. package/build/website/vite.config.ts +38 -3
  90. package/package.json +25 -16
  91. package/build/website/globals.d.ts +0 -8
  92. package/build/website/helpers/articles.ts +0 -24
  93. package/build/website/public/logo.svg +0 -72
  94. package/build/website/routes/index.tsx +0 -13
@@ -1,30 +1,25 @@
1
- import type { ComponentProps } from "react"
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 src = props.src ?? ""
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
- return (
11
- <Zoom>
12
- <img {...props} src={src.slice(0, -6)} className="dark:hidden" />
13
- </Zoom>
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
- return (
26
- <Zoom>
27
- <img {...props} />
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 toPathname(filePath: string) {
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: config.articles.include,
88
- exclude: config.articles.exclude,
89
- schema: z.object({
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 filePath = resolve(config.root, document._meta.filePath)
99
- const content = resolveIncludes(document.content, filePath)
100
- const title = document.title ?? extractTitle(content, document._meta.path)
101
- const icon = document.icon ?? pickDefaultIcon(document._meta.path)
102
- const pathname = document.pathname ?? toPathname(document._meta.filePath)
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
- { ...document, content },
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, document._meta.filePath),
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: { light: "catppuccin-latte", dark: "catppuccin-mocha" },
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 toc = extractToc(content)
184
+ const tocItems = extractToc(content)
162
185
  const searchText = extractSearchText(content)
163
- const lastUpdated = getLastUpdated(document._meta.filePath)
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
- ...document,
166
- filePath: document._meta.filePath,
191
+ ...doc,
192
+ file: doc._meta.filePath,
167
193
  lastUpdated,
194
+ image,
168
195
  title,
169
196
  icon,
170
- pathname,
171
- toc,
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 { PanelLeftIcon } from "lucide-react"
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
- <PanelLeftIcon />
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
  },
@@ -1,3 +1,7 @@
1
+ interface ImportMetaEnv {
2
+ readonly CONFIG: import("../models/config.ts").WebsiteConfig
3
+ }
4
+
1
5
  declare module "content-collections" {
2
6
  import type configuration from "./content-collections.ts"
3
7
  import type { GetTypeByName } from "@content-collections/core"
@@ -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,4 @@
1
+ import { z } from "zod"
2
+
3
+ /** Zod schema for a non-empty string (rejects ""). */
4
+ export const nonEmptyString = z.string().min(1)
@@ -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
+ }