livemark 0.16.0 → 0.18.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 (49) 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 +13 -11
  5. package/build/components/SearchDialog.js +2 -3
  6. package/build/components/SidebarArticles.js +3 -3
  7. package/build/components/SidebarLinks.js +3 -3
  8. package/build/components/SidebarSections.js +8 -8
  9. package/build/content/article.js +3 -3
  10. package/build/content-collections.js +2 -3
  11. package/build/helpers/article-icon.d.ts +4 -4
  12. package/build/helpers/article-icon.js +8 -36
  13. package/build/helpers/article.d.ts +1 -1
  14. package/build/helpers/article.js +1 -1
  15. package/build/helpers/resolve-base.d.ts +6 -0
  16. package/build/helpers/resolve-base.js +13 -0
  17. package/build/helpers/resolve-base.unit.d.ts +1 -0
  18. package/build/helpers/resolve-base.unit.js +49 -0
  19. package/build/helpers/section.d.ts +7 -3
  20. package/build/helpers/section.js +16 -10
  21. package/build/helpers/section.unit.js +32 -2
  22. package/build/hooks/media.d.ts +1 -1
  23. package/build/hooks/media.js +2 -2
  24. package/build/models/article.d.ts +1 -1
  25. package/build/models/article.js +1 -1
  26. package/build/models/config.d.ts +29 -11
  27. package/build/models/config.js +9 -11
  28. package/build/models/section.d.ts +7 -5
  29. package/build/models/section.js +4 -4
  30. package/package.json +1 -1
  31. package/source/actions/config/define.ts +4 -6
  32. package/source/actions/config/load.ts +26 -6
  33. package/source/components/Layout.tsx +16 -16
  34. package/source/components/SearchDialog.tsx +4 -2
  35. package/source/components/SidebarArticles.tsx +2 -2
  36. package/source/components/SidebarLinks.tsx +2 -2
  37. package/source/components/SidebarSections.tsx +11 -8
  38. package/source/content/article.ts +2 -2
  39. package/source/content-collections.ts +1 -2
  40. package/source/helpers/article-icon.ts +7 -38
  41. package/source/helpers/article.ts +1 -1
  42. package/source/helpers/resolve-base.ts +11 -0
  43. package/source/helpers/resolve-base.unit.ts +61 -0
  44. package/source/helpers/section.ts +13 -12
  45. package/source/helpers/section.unit.ts +43 -1
  46. package/source/hooks/media.ts +1 -1
  47. package/source/models/article.ts +1 -1
  48. package/source/models/config.ts +32 -14
  49. package/source/models/section.ts +7 -5
@@ -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,15 +1,16 @@
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
- external: "external-link",
8
- }
9
-
10
- /** Return the section's icon, falling back to a type-based default */
11
- export function sectionIcon(section: Pick<Section, "icon" | "type">) {
12
- return section.icon ?? DEFAULT_ICONS[section.type]
1
+ /** Whether a custom section's URL should highlight as active for the
2
+ * current pathname. Only `/`-leading internal paths participate;
3
+ * protocol-absolute (`https://…`, `//cdn…`) URLs never highlight.
4
+ * Match semantics mirror routed sections — exact path or any
5
+ * descendant. A URL of `/` only highlights the home page itself
6
+ * (otherwise it would always match). */
7
+ export function customSectionActive(url: string, pathname: string) {
8
+ if (!url.startsWith("/") || url.startsWith("//")) return false
9
+ const stripped = url.split(/[?#]/)[0] ?? ""
10
+ const target = `/${stripped.replace(/^\/|\/$/g, "")}/`
11
+ const current = `/${pathname.replace(/^\/|\/$/g, "")}/`
12
+ if (target === "/") return current === "/"
13
+ return current === target || current.startsWith(target)
13
14
  }
14
15
 
15
16
  /** Find the section whose prefix is the longest prefix of the article path */
@@ -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
+ })
@@ -1,6 +1,6 @@
1
1
  import { useSyncExternalStore } from "react"
2
2
 
3
- export const MOBILE_BREAKPOINT = 768
3
+ export const MOBILE_BREAKPOINT = 1024
4
4
 
5
5
  export function useIsMobile() {
6
6
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
@@ -45,6 +45,6 @@ export interface ArticleNode {
45
45
  path: string
46
46
  title: string
47
47
  label?: string
48
- icon: string
48
+ icon?: string
49
49
  children: ArticleNode[]
50
50
  }
@@ -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)
@@ -29,10 +29,12 @@ export const ChangelogSection = BaseSection.extend({
29
29
  version: z.boolean().optional(),
30
30
  })
31
31
 
32
- /** External section: renders as a link to an arbitrary URL. */
33
- export type ExternalSection = z.infer<typeof ExternalSection>
34
- export const ExternalSection = BaseSection.extend({
35
- type: z.literal("external"),
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"),
36
38
  url: z.string(),
37
39
  })
38
40
 
@@ -54,6 +56,6 @@ export const Section = z.preprocess(
54
56
  ArticleSection,
55
57
  BlogSection,
56
58
  ChangelogSection,
57
- ExternalSection,
59
+ CustomSection,
58
60
  ]),
59
61
  )