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.
- package/build/actions/config/define.d.ts +67 -3
- package/build/actions/config/define.js +3 -3
- package/build/actions/config/load.js +23 -2
- package/build/components/Layout.js +11 -4
- package/build/components/SidebarArticles.js +9 -9
- package/build/components/SidebarSections.js +11 -5
- package/build/content/article.d.ts +10 -10
- package/build/content/article.js +19 -17
- package/build/helpers/article.d.ts +1 -15
- package/build/helpers/article.js +1 -34
- package/build/helpers/resolve-base.d.ts +6 -0
- package/build/helpers/resolve-base.js +13 -0
- package/build/helpers/resolve-base.unit.d.ts +1 -0
- package/build/helpers/resolve-base.unit.js +49 -0
- package/build/helpers/section.d.ts +10 -2
- package/build/helpers/section.js +19 -2
- package/build/helpers/section.unit.js +32 -2
- package/build/models/article.d.ts +0 -9
- package/build/models/article.js +1 -2
- package/build/models/config.d.ts +62 -19
- package/build/models/config.js +9 -11
- package/build/models/patch.d.ts +0 -1
- package/build/models/section.d.ts +29 -6
- package/build/models/section.js +14 -3
- package/package.json +1 -1
- package/source/actions/config/define.ts +4 -6
- package/source/actions/config/load.ts +26 -6
- package/source/components/Layout.tsx +20 -5
- package/source/components/SidebarArticles.tsx +17 -24
- package/source/components/SidebarSections.tsx +23 -3
- package/source/content/article.ts +20 -26
- package/source/helpers/article.ts +1 -39
- package/source/helpers/resolve-base.ts +11 -0
- package/source/helpers/resolve-base.unit.ts +61 -0
- package/source/helpers/section.ts +18 -2
- package/source/helpers/section.unit.ts +43 -1
- package/source/models/article.ts +0 -10
- package/source/models/config.ts +32 -14
- 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 {
|
|
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
|
+
})
|
package/source/models/article.ts
CHANGED
|
@@ -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
|
-
}
|
package/source/models/config.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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`
|
|
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)
|
package/source/models/section.ts
CHANGED
|
@@ -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", [
|
|
55
|
+
z.discriminatedUnion("type", [
|
|
56
|
+
ArticleSection,
|
|
57
|
+
BlogSection,
|
|
58
|
+
ChangelogSection,
|
|
59
|
+
CustomSection,
|
|
60
|
+
]),
|
|
45
61
|
)
|