livemark 0.15.0 → 0.17.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 (39) hide show
  1. package/build/actions/config/define.d.ts +67 -3
  2. package/build/actions/config/define.js +3 -3
  3. package/build/actions/config/load.js +23 -2
  4. package/build/components/Layout.js +11 -4
  5. package/build/components/SidebarArticles.js +9 -9
  6. package/build/components/SidebarSections.js +11 -5
  7. package/build/content/article.d.ts +10 -10
  8. package/build/content/article.js +19 -17
  9. package/build/helpers/article.d.ts +1 -15
  10. package/build/helpers/article.js +1 -34
  11. package/build/helpers/resolve-base.d.ts +6 -0
  12. package/build/helpers/resolve-base.js +13 -0
  13. package/build/helpers/resolve-base.unit.d.ts +1 -0
  14. package/build/helpers/resolve-base.unit.js +49 -0
  15. package/build/helpers/section.d.ts +10 -2
  16. package/build/helpers/section.js +19 -2
  17. package/build/helpers/section.unit.js +32 -2
  18. package/build/models/article.d.ts +0 -9
  19. package/build/models/article.js +1 -2
  20. package/build/models/config.d.ts +62 -19
  21. package/build/models/config.js +9 -11
  22. package/build/models/patch.d.ts +0 -1
  23. package/build/models/section.d.ts +29 -6
  24. package/build/models/section.js +14 -3
  25. package/package.json +1 -1
  26. package/source/actions/config/define.ts +4 -6
  27. package/source/actions/config/load.ts +26 -6
  28. package/source/components/Layout.tsx +20 -5
  29. package/source/components/SidebarArticles.tsx +17 -24
  30. package/source/components/SidebarSections.tsx +23 -3
  31. package/source/content/article.ts +20 -26
  32. package/source/helpers/article.ts +1 -39
  33. package/source/helpers/resolve-base.ts +11 -0
  34. package/source/helpers/resolve-base.unit.ts +61 -0
  35. package/source/helpers/section.ts +18 -2
  36. package/source/helpers/section.unit.ts +43 -1
  37. package/source/models/article.ts +0 -10
  38. package/source/models/config.ts +32 -14
  39. package/source/models/section.ts +18 -2
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vite-plus/test"
2
+ import { resolveBase } from "./resolve-base.ts"
3
+
4
+ describe("resolveBase", () => {
5
+ it("should return undefined for undefined input", () => {
6
+ expect(resolveBase(undefined, "build")).toBeUndefined()
7
+ expect(resolveBase(undefined, "serve")).toBeUndefined()
8
+ })
9
+
10
+ it("should return undefined for empty or root-only string", () => {
11
+ expect(resolveBase("", "build")).toBeUndefined()
12
+ expect(resolveBase("/", "build")).toBeUndefined()
13
+ })
14
+
15
+ it("should normalize a string without slashes", () => {
16
+ expect(resolveBase("repo", "build")).toBe("/repo")
17
+ })
18
+
19
+ it("should normalize a string with leading slash", () => {
20
+ expect(resolveBase("/repo", "build")).toBe("/repo")
21
+ })
22
+
23
+ it("should normalize a string with trailing slash", () => {
24
+ expect(resolveBase("repo/", "build")).toBe("/repo")
25
+ })
26
+
27
+ it("should normalize a string with both slashes", () => {
28
+ expect(resolveBase("/repo/", "build")).toBe("/repo")
29
+ })
30
+
31
+ it("should preserve multi-segment paths", () => {
32
+ expect(resolveBase("/a/b/", "build")).toBe("/a/b")
33
+ })
34
+
35
+ it("should call a function with command=build", () => {
36
+ const fn = (cmd: "build" | "serve") => (cmd === "build" ? "prod" : "dev")
37
+ expect(resolveBase(fn, "build")).toBe("/prod")
38
+ })
39
+
40
+ it("should call a function with command=serve", () => {
41
+ const fn = (cmd: "build" | "serve") => (cmd === "build" ? "prod" : "dev")
42
+ expect(resolveBase(fn, "serve")).toBe("/dev")
43
+ })
44
+
45
+ it("should handle a function returning undefined", () => {
46
+ const fn = (cmd: "build" | "serve") =>
47
+ cmd === "serve" ? undefined : "prod"
48
+ expect(resolveBase(fn, "serve")).toBeUndefined()
49
+ expect(resolveBase(fn, "build")).toBe("/prod")
50
+ })
51
+
52
+ it("should handle a function returning empty string", () => {
53
+ const fn = () => ""
54
+ expect(resolveBase(fn, "build")).toBeUndefined()
55
+ })
56
+
57
+ it("should normalize the function-returned string", () => {
58
+ const fn = () => "/repo/"
59
+ expect(resolveBase(fn, "build")).toBe("/repo")
60
+ })
61
+ })
@@ -1,16 +1,32 @@
1
1
  import type { Section } from "../models/section.ts"
2
2
 
3
- const DEFAULT_ICONS: Record<Section["type"], string> = {
3
+ const DEFAULT_ICONS: Partial<Record<Section["type"], string>> = {
4
4
  article: "book-open",
5
5
  blog: "rss",
6
6
  changelog: "history",
7
7
  }
8
8
 
9
- /** Return the section's icon, falling back to a type-based default */
9
+ /** Return the section's icon, falling back to a type-based default.
10
+ * Custom sections have no default — the user opts in via `icon`. */
10
11
  export function sectionIcon(section: Pick<Section, "icon" | "type">) {
11
12
  return section.icon ?? DEFAULT_ICONS[section.type]
12
13
  }
13
14
 
15
+ /** Whether a custom section's URL should highlight as active for the
16
+ * current pathname. Only `/`-leading internal paths participate;
17
+ * protocol-absolute (`https://…`, `//cdn…`) URLs never highlight.
18
+ * Match semantics mirror routed sections — exact path or any
19
+ * descendant. A URL of `/` only highlights the home page itself
20
+ * (otherwise it would always match). */
21
+ export function customSectionActive(url: string, pathname: string) {
22
+ if (!url.startsWith("/") || url.startsWith("//")) return false
23
+ const stripped = url.split(/[?#]/)[0] ?? ""
24
+ const target = `/${stripped.replace(/^\/|\/$/g, "")}/`
25
+ const current = `/${pathname.replace(/^\/|\/$/g, "")}/`
26
+ if (target === "/") return current === "/"
27
+ return current === target || current.startsWith(target)
28
+ }
29
+
14
30
  /** Find the section whose prefix is the longest prefix of the article path */
15
31
  export function matchSection<S extends { prefix: string }>(
16
32
  articlePath: string,
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, it } from "vite-plus/test"
2
- import { matchSection, partitionBySection } from "./section.ts"
2
+ import {
3
+ customSectionActive,
4
+ matchSection,
5
+ partitionBySection,
6
+ } from "./section.ts"
3
7
 
4
8
  describe("matchSection", () => {
5
9
  const sections = [
@@ -69,3 +73,41 @@ describe("partitionBySection", () => {
69
73
  ])
70
74
  })
71
75
  })
76
+
77
+ describe("customSectionActive", () => {
78
+ it("should not match an absolute http url", () => {
79
+ expect(customSectionActive("https://x.com/y", "/y/")).toBe(false)
80
+ expect(customSectionActive("http://x.com/y", "/y/")).toBe(false)
81
+ })
82
+
83
+ it("should not match a protocol-relative url", () => {
84
+ expect(customSectionActive("//cdn.x.com/y", "/y/")).toBe(false)
85
+ })
86
+
87
+ it("should not match a hash url", () => {
88
+ expect(customSectionActive("#section", "/section/")).toBe(false)
89
+ })
90
+
91
+ it("should match the exact path", () => {
92
+ expect(customSectionActive("/about", "/about/")).toBe(true)
93
+ expect(customSectionActive("/about/", "/about/")).toBe(true)
94
+ })
95
+
96
+ it("should match a descendant path", () => {
97
+ expect(customSectionActive("/about", "/about/team/")).toBe(true)
98
+ })
99
+
100
+ it("should not match a sibling path with shared prefix", () => {
101
+ expect(customSectionActive("/about", "/aboutus/")).toBe(false)
102
+ })
103
+
104
+ it("should match exact root only", () => {
105
+ expect(customSectionActive("/", "/")).toBe(true)
106
+ expect(customSectionActive("/", "/about/")).toBe(false)
107
+ })
108
+
109
+ it("should ignore query and hash on the url", () => {
110
+ expect(customSectionActive("/about?x=1", "/about/")).toBe(true)
111
+ expect(customSectionActive("/about#top", "/about/team/")).toBe(true)
112
+ })
113
+ })
@@ -12,7 +12,6 @@ export const Article = z.object({
12
12
  icon: nonEmptyString.optional(),
13
13
  order: z.number().optional(),
14
14
  path: nonEmptyString.optional(),
15
- group: nonEmptyString.optional(),
16
15
  sidebar: z.boolean().default(true),
17
16
  toc: z.boolean().default(true),
18
17
  image: nonEmptyString.optional(),
@@ -49,12 +48,3 @@ export interface ArticleNode {
49
48
  icon: string
50
49
  children: ArticleNode[]
51
50
  }
52
-
53
- /**
54
- * A contiguous section of root articles sharing the same `group` label.
55
- * `name: undefined` represents the leading ungrouped section (at most one).
56
- */
57
- export interface ArticleGroup {
58
- name?: string
59
- nodes: ArticleNode[]
60
- }
@@ -15,22 +15,33 @@ export type ViteOverride =
15
15
  | ViteUserConfig
16
16
  | ((env: ConfigEnv) => ViteUserConfig | Promise<ViteUserConfig>)
17
17
 
18
+ /** Sub-path mount. String form is used as-is; function form is called
19
+ * with Vite's `command` (`"serve"` in dev, `"build"` in production) so
20
+ * the user can branch (typical case: serve at `/` in dev but at
21
+ * `/repo/` in production for GitHub Pages). The build pipeline
22
+ * resolves it to a string before any runtime sees it. */
23
+ export type Base = string | ((command: "build" | "serve") => string | undefined)
24
+
18
25
  /**
19
26
  * User-provided configuration for Livemark.
27
+ *
28
+ * `vite` and `base` are lifted from `unknown` to their public types so
29
+ * `defineConfig` shows the right shape in editors. Use this type for
30
+ * the input to `defineConfig` — it keeps defaulted fields optional
31
+ * (because zod input ≠ output for fields with `.default()`).
20
32
  */
21
- export type UserConfig = Omit<z.infer<typeof UserConfig>, "vite"> & {
33
+ export type UserConfig = Omit<z.input<typeof UserConfig>, "vite" | "base"> & {
22
34
  vite?: ViteOverride
35
+ base?: Base
23
36
  }
24
37
  export const UserConfig = z.object({
25
38
  site: z.string().optional(),
26
- base: z
27
- .string()
28
- .optional()
29
- .transform(v => {
30
- if (!v) return undefined
31
- const trimmed = v.replace(/^\/|\/$/g, "")
32
- return trimmed ? `/${trimmed}` : undefined
33
- }),
39
+ // Stored as `unknown` in the schema (zod can't type a function and
40
+ // string union meaningfully); the public TypeScript surface above
41
+ // lifts it back to `Base` for editor support. The build pipeline
42
+ // resolves it via `resolveBase()` before any runtime consumer sees
43
+ // it, so the runtime `WebsiteConfig.base` is strictly `string`.
44
+ base: z.unknown().optional(),
34
45
  favicon: z.string().optional(),
35
46
  logo: z.string().optional(),
36
47
  title: z.string().default("Livemark"),
@@ -48,17 +59,20 @@ export const UserConfig = z.object({
48
59
  vite: z.unknown().optional(),
49
60
  })
50
61
 
51
- /** Website configuration injected at build time via Vite define */
62
+ /** Website configuration injected at build time via Vite define.
63
+ * `base` is strictly a string here — the build pipeline has already
64
+ * resolved any function form via `resolveBase()`. */
52
65
  export type WebsiteConfig = z.infer<typeof WebsiteConfig>
53
66
  export const WebsiteConfig = UserConfig.pick({
54
67
  title: true,
55
68
  description: true,
56
69
  site: true,
57
- base: true,
58
70
  favicon: true,
59
71
  logo: true,
60
72
  links: true,
61
73
  sections: true,
74
+ }).extend({
75
+ base: z.string().optional(),
62
76
  })
63
77
 
64
78
  /**
@@ -73,9 +87,13 @@ export const SystemConfig = z.object({
73
87
  /**
74
88
  * Fully resolved Livemark configuration.
75
89
  *
76
- * `vite` stays `unknown` here because that's what the runtime schema
90
+ * `vite` stays `unknown` because that's what the runtime schema
77
91
  * produces — consumer-facing typing happens via {@link UserConfig} so
78
- * `defineConfig` shows the right shape in editors.
92
+ * `defineConfig` shows the right shape in editors. `base` is lifted to
93
+ * `string | undefined` because `loadConfig()` resolves any function
94
+ * form via `resolveBase()` before returning.
79
95
  */
80
- export type Config = z.infer<typeof Config>
96
+ export type Config = Omit<z.infer<typeof Config>, "base"> & {
97
+ base?: string
98
+ }
81
99
  export const Config = UserConfig.merge(SystemConfig)
@@ -3,7 +3,6 @@ import { z } from "zod"
3
3
  const BaseSection = z.object({
4
4
  icon: z.string().optional(),
5
5
  title: z.string(),
6
- prefix: z.string(),
7
6
  position: z.enum(["header", "sidebar"]).default("header"),
8
7
  })
9
8
 
@@ -11,22 +10,34 @@ const BaseSection = z.object({
11
10
  export type ArticleSection = z.infer<typeof ArticleSection>
12
11
  export const ArticleSection = BaseSection.extend({
13
12
  type: z.literal("article"),
13
+ prefix: z.string(),
14
14
  })
15
15
 
16
16
  /** Blog section: date-sorted posts with tags and index. */
17
17
  export type BlogSection = z.infer<typeof BlogSection>
18
18
  export const BlogSection = BaseSection.extend({
19
19
  type: z.literal("blog"),
20
+ prefix: z.string(),
20
21
  })
21
22
 
22
23
  /** Changelog section: single file or GitHub releases, split per version. */
23
24
  export type ChangelogSection = z.infer<typeof ChangelogSection>
24
25
  export const ChangelogSection = BaseSection.extend({
25
26
  type: z.literal("changelog"),
27
+ prefix: z.string(),
26
28
  source: z.string(),
27
29
  version: z.boolean().optional(),
28
30
  })
29
31
 
32
+ /** Custom section: renders as a link to an arbitrary URL — absolute
33
+ * (`https://…`) or internal (`/changelog`). Internal URLs participate
34
+ * in active-state highlighting like routed sections. */
35
+ export type CustomSection = z.infer<typeof CustomSection>
36
+ export const CustomSection = BaseSection.extend({
37
+ type: z.literal("custom"),
38
+ url: z.string(),
39
+ })
40
+
30
41
  /**
31
42
  * A site section. Discriminated on `type`.
32
43
  * When `type` is omitted, it defaults to "article" so plain
@@ -41,5 +52,10 @@ export const Section = z.preprocess(
41
52
  const record = input as Record<string, unknown>
42
53
  return "type" in record ? record : { ...record, type: "article" }
43
54
  },
44
- z.discriminatedUnion("type", [ArticleSection, BlogSection, ChangelogSection]),
55
+ z.discriminatedUnion("type", [
56
+ ArticleSection,
57
+ BlogSection,
58
+ ChangelogSection,
59
+ CustomSection,
60
+ ]),
45
61
  )