@ciderpress/ui 1.0.0-rc.3 → 1.0.0-rc.4
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/dist/index.d.ts +1 -1
- package/dist/index.mjs +4 -3
- package/dist/node.d.ts +1 -1
- package/dist/node.mjs +316 -50
- package/dist/theme/components/footer/site-footer.tsx +68 -9
- package/dist/theme/components/home/feature-card.tsx +5 -8
- package/dist/theme/components/home/feature.tsx +31 -11
- package/dist/theme/components/home/hero-demo-custom.tsx +116 -0
- package/dist/theme/components/home/hero-demo.css +27 -0
- package/dist/theme/components/home/layout.tsx +149 -26
- package/dist/theme/components/home/split-visual-custom.tsx +26 -0
- package/dist/theme/components/home/workspaces.tsx +39 -3
- package/dist/theme/components/nav/branch-tag.tsx +7 -7
- package/dist/theme/components/nav/ciderpress-header.css +2 -1
- package/dist/theme/components/nav/ciderpress-header.tsx +26 -3
- package/dist/theme/components/nav/ciderpress-nav-social-links.tsx +9 -7
- package/dist/theme/components/nav/floating-branch-indicator.tsx +7 -7
- package/dist/theme/components/nav/header-icon.css +24 -0
- package/dist/theme/components/nav/header-icon.tsx +119 -0
- package/dist/theme/components/nav/header-logo.css +16 -0
- package/dist/theme/components/nav/header-logo.tsx +200 -0
- package/dist/theme/components/nav/layout.tsx +45 -7
- package/dist/theme/components/nav/nav-logo.tsx +5 -1
- package/dist/theme/components/shared/card-icon.tsx +37 -0
- package/dist/theme/components/shared/ciderpress-logo.tsx +5 -1
- package/dist/theme/components/shared/resolve-card-icon.ts +53 -12
- package/dist/theme/components/shared/section-card.tsx +9 -6
- package/dist/theme/components/sidebar/sidebar-links.tsx +6 -2
- package/dist/theme/components/theme-provider.tsx +27 -11
- package/dist/theme/components/workspaces/card.tsx +7 -9
- package/dist/theme/hooks/use-ciderpress.ts +60 -5
- package/dist/theme/lib/read-social-links.ts +4 -3
- package/dist/theme/lib/theme-favicon.ts +1 -1
- package/dist/theme/lib/with-mount-base.ts +31 -0
- package/package.json +13 -13
- package/src/theme/components/footer/site-footer.tsx +68 -9
- package/src/theme/components/home/feature-card.tsx +5 -8
- package/src/theme/components/home/feature.tsx +31 -11
- package/src/theme/components/home/hero-demo-custom.tsx +116 -0
- package/src/theme/components/home/hero-demo.css +27 -0
- package/src/theme/components/home/layout.tsx +149 -26
- package/src/theme/components/home/split-visual-custom.tsx +26 -0
- package/src/theme/components/home/workspaces.tsx +39 -3
- package/src/theme/components/nav/branch-tag.tsx +7 -7
- package/src/theme/components/nav/ciderpress-header.css +2 -1
- package/src/theme/components/nav/ciderpress-header.tsx +26 -3
- package/src/theme/components/nav/ciderpress-nav-social-links.tsx +9 -7
- package/src/theme/components/nav/floating-branch-indicator.tsx +7 -7
- package/src/theme/components/nav/header-icon.css +24 -0
- package/src/theme/components/nav/header-icon.tsx +119 -0
- package/src/theme/components/nav/header-logo.css +16 -0
- package/src/theme/components/nav/header-logo.tsx +200 -0
- package/src/theme/components/nav/layout.tsx +45 -7
- package/src/theme/components/nav/nav-logo.tsx +5 -1
- package/src/theme/components/shared/card-icon.tsx +37 -0
- package/src/theme/components/shared/ciderpress-logo.tsx +5 -1
- package/src/theme/components/shared/resolve-card-icon.ts +53 -12
- package/src/theme/components/shared/section-card.tsx +9 -6
- package/src/theme/components/sidebar/sidebar-links.tsx +6 -2
- package/src/theme/components/theme-provider.tsx +27 -11
- package/src/theme/components/workspaces/card.tsx +7 -9
- package/src/theme/hooks/use-ciderpress.ts +60 -5
- package/src/theme/lib/read-social-links.ts +4 -3
- package/src/theme/lib/theme-favicon.ts +1 -1
- package/src/theme/lib/with-mount-base.ts +31 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { HomeShowcaseConfig } from '@ciderpress/config'
|
|
2
|
+
import { useFrontmatter } from '@rspress/core/runtime'
|
|
2
3
|
import { match, P } from 'massaman/match'
|
|
3
4
|
import type React from 'react'
|
|
4
5
|
|
|
@@ -7,15 +8,29 @@ import type { WorkspaceGroupData } from '../../hooks/use-ciderpress'
|
|
|
7
8
|
import { WorkspaceCard } from '../workspaces/card'
|
|
8
9
|
import { WorkspaceGrid } from '../workspaces/grid'
|
|
9
10
|
|
|
11
|
+
interface FrontmatterWorkspacesHeading {
|
|
12
|
+
readonly label?: string
|
|
13
|
+
readonly title?: string
|
|
14
|
+
readonly subtitle?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
10
17
|
/**
|
|
11
18
|
* Smart orchestrator that reads workspace data from themeConfig
|
|
12
19
|
* and renders workspace groups with the correct card component per type.
|
|
13
20
|
*
|
|
21
|
+
* Optionally renders a top-level heading above the workspace groups
|
|
22
|
+
* when `home.showcase.heading` is configured. The sync engine writes
|
|
23
|
+
* it into frontmatter as `workspacesHeading`.
|
|
24
|
+
*
|
|
14
25
|
* @returns React element with workspace groups or null
|
|
15
26
|
*/
|
|
16
27
|
export function HomeWorkspaces(): React.ReactElement | null {
|
|
17
28
|
const { workspaces, home } = useCiderpress()
|
|
18
|
-
const gridConfig = home && home.
|
|
29
|
+
const gridConfig = home && home.showcase
|
|
30
|
+
const { frontmatter } = useFrontmatter()
|
|
31
|
+
const heading = (frontmatter as Record<string, unknown>).workspacesHeading as
|
|
32
|
+
| FrontmatterWorkspacesHeading
|
|
33
|
+
| undefined
|
|
19
34
|
|
|
20
35
|
return match(workspaces)
|
|
21
36
|
.with(
|
|
@@ -23,6 +38,27 @@ export function HomeWorkspaces(): React.ReactElement | null {
|
|
|
23
38
|
(groups) => (
|
|
24
39
|
<div className="cp-workspace-section">
|
|
25
40
|
<hr className="cp-divider" />
|
|
41
|
+
{match(heading)
|
|
42
|
+
.with(undefined, () => null)
|
|
43
|
+
.otherwise((h) => (
|
|
44
|
+
<div className="cp-feature-section-head">
|
|
45
|
+
{match(h.label)
|
|
46
|
+
.with(undefined, () => null)
|
|
47
|
+
.otherwise((e) => (
|
|
48
|
+
<div className="cp-feature-section-head__eyebrow">{e}</div>
|
|
49
|
+
))}
|
|
50
|
+
{match(h.title)
|
|
51
|
+
.with(undefined, () => null)
|
|
52
|
+
.otherwise((t) => (
|
|
53
|
+
<h2 className="cp-feature-section-head__title">{t}</h2>
|
|
54
|
+
))}
|
|
55
|
+
{match(h.subtitle)
|
|
56
|
+
.with(undefined, () => null)
|
|
57
|
+
.otherwise((s) => (
|
|
58
|
+
<p className="cp-feature-section-head__sub">{s}</p>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
))}
|
|
26
62
|
{groups.map((group) => renderGroup(group, gridConfig))}
|
|
27
63
|
</div>
|
|
28
64
|
)
|
|
@@ -40,7 +76,7 @@ export function HomeWorkspaces(): React.ReactElement | null {
|
|
|
40
76
|
*/
|
|
41
77
|
function renderGroup(
|
|
42
78
|
group: WorkspaceGroupData,
|
|
43
|
-
gridConfig:
|
|
79
|
+
gridConfig: HomeShowcaseConfig | undefined
|
|
44
80
|
): React.ReactElement {
|
|
45
81
|
const titleLines = gridConfig && gridConfig.truncate && gridConfig.truncate.title
|
|
46
82
|
const descLines = gridConfig && gridConfig.truncate && gridConfig.truncate.description
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { SiteConfig } from '@ciderpress/config'
|
|
2
1
|
import { match, P } from 'massaman/match'
|
|
3
2
|
import type React from 'react'
|
|
4
3
|
|
|
4
|
+
import type { CiderpressSiteBlock } from '../../hooks/use-ciderpress'
|
|
5
5
|
import { useCiderpress } from '../../hooks/use-ciderpress'
|
|
6
6
|
|
|
7
7
|
import './branch-tag.css'
|
|
@@ -12,7 +12,7 @@ declare const __CIDERPRESS_GIT_BRANCH__: string | undefined
|
|
|
12
12
|
/**
|
|
13
13
|
* Git branch tag — pill-shaped badge rendered via the `beforeNavMenu`
|
|
14
14
|
* layout slot. Hidden when on default branches (`main` or `master`) or
|
|
15
|
-
* when no `
|
|
15
|
+
* when no `editLink.repo` is configured. Uses the pixelarticons:git-branch
|
|
16
16
|
* icon.
|
|
17
17
|
*
|
|
18
18
|
* @returns React element, or `null` when on a default branch / unconfigured.
|
|
@@ -61,7 +61,7 @@ function resolveBranch(): string {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
|
-
* Build a GitHub `/tree/<branch>` URL from the configured `
|
|
64
|
+
* Build a GitHub `/tree/<branch>` URL from the configured `editLink.repo`.
|
|
65
65
|
*
|
|
66
66
|
* Accepts either a full URL (used as-is) or an `org/repo` slug
|
|
67
67
|
* (prefixed with `https://github.com/`). Returns `null` when `repo` is
|
|
@@ -83,14 +83,14 @@ function buildBranchHref(params: {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
|
-
* Pull the configured edit-repo slug off the site
|
|
87
|
-
* with explicit null checks rather than optional chaining.
|
|
86
|
+
* Pull the configured edit-repo slug off the serialised site block,
|
|
87
|
+
* expressed with explicit null checks rather than optional chaining.
|
|
88
88
|
*
|
|
89
89
|
* @private
|
|
90
|
-
* @param site - The
|
|
90
|
+
* @param site - The runtime site block (may be undefined).
|
|
91
91
|
* @returns The configured `edit.repo` value or `undefined`.
|
|
92
92
|
*/
|
|
93
|
-
function resolveRepo(site:
|
|
93
|
+
function resolveRepo(site: CiderpressSiteBlock | undefined): string | undefined {
|
|
94
94
|
if (site === undefined) {
|
|
95
95
|
return undefined
|
|
96
96
|
}
|
|
@@ -2,19 +2,29 @@ import { clsx } from 'clsx'
|
|
|
2
2
|
import type React from 'react'
|
|
3
3
|
|
|
4
4
|
import { RouteLink } from '../../lib/route-link.tsx'
|
|
5
|
-
import { CiderpressLogo } from '../shared/ciderpress-logo'
|
|
6
5
|
import { CiderpressNavHamburger } from './ciderpress-nav-hamburger'
|
|
7
6
|
import { CiderpressNavMenu } from './ciderpress-nav-menu'
|
|
8
7
|
import type { CiderpressNavMenuItem } from './ciderpress-nav-menu'
|
|
9
8
|
import { CiderpressNavSearch } from './ciderpress-nav-search'
|
|
10
9
|
import { CiderpressNavSocialLinks } from './ciderpress-nav-social-links'
|
|
11
10
|
import type { CiderpressSocialLink } from './ciderpress-nav-social-links'
|
|
11
|
+
import { HeaderIcon } from './header-icon'
|
|
12
|
+
import { HeaderLogo } from './header-logo'
|
|
12
13
|
import { NavDivider } from './nav-divider'
|
|
13
14
|
import { TopbarCTA } from './topbar-cta'
|
|
14
15
|
import { VariantToggle } from './variant-toggle'
|
|
15
16
|
|
|
16
17
|
import './ciderpress-header.css'
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Build-time flag: `true` when the active theme declares more than one
|
|
21
|
+
* variant AND `data-cp-variants` won't collapse the `<VariantToggle />`
|
|
22
|
+
* to `display: none`. Drives whether the trailing-cluster divider is
|
|
23
|
+
* rendered — when the toggle is hidden and neither socials nor CTA
|
|
24
|
+
* follow, the divider has nothing to separate and is omitted.
|
|
25
|
+
*/
|
|
26
|
+
declare const __CIDERPRESS_HAS_VARIANT_TOGGLE__: boolean
|
|
27
|
+
|
|
18
28
|
/**
|
|
19
29
|
* Props for the ciderpress site header.
|
|
20
30
|
*/
|
|
@@ -57,6 +67,18 @@ export interface CiderpressHeaderProps {
|
|
|
57
67
|
* @returns Sticky header element
|
|
58
68
|
*/
|
|
59
69
|
export function CiderpressHeader(props: CiderpressHeaderProps): React.ReactElement {
|
|
70
|
+
// Dividers only render when they actually separate two visible
|
|
71
|
+
// clusters — orphan dividers (nothing on the right) are visually
|
|
72
|
+
// noisy. `<VariantToggle />` is hidden by CSS for single-variant
|
|
73
|
+
// themes; the build-time flag captures that decision so the React
|
|
74
|
+
// tree matches the painted output. The search/menu inner divider is
|
|
75
|
+
// unconditional — both clusters always render some chrome, and
|
|
76
|
+
// gating on `navItems.length` would flip after hydration (Rspress's
|
|
77
|
+
// nav data isn't populated at SSG) and produce a hydration warning.
|
|
78
|
+
const hasSocials = props.socialLinks.length > 0
|
|
79
|
+
const hasCta = props.topbarCta !== undefined
|
|
80
|
+
const hasTrailingCluster = __CIDERPRESS_HAS_VARIANT_TOGGLE__ || hasSocials || hasCta
|
|
81
|
+
|
|
60
82
|
// Landing / home pages live inside a constrained max-width container;
|
|
61
83
|
// doc pages stretch full-width (sidebar + article). The header mirrors
|
|
62
84
|
// the content shell so it never floats outside the page rhythm.
|
|
@@ -78,7 +100,8 @@ export function CiderpressHeader(props: CiderpressHeaderProps): React.ReactEleme
|
|
|
78
100
|
topbarCta={props.topbarCta}
|
|
79
101
|
/>
|
|
80
102
|
<RouteLink href="/" className="cp-header-logo" aria-label="Home">
|
|
81
|
-
<
|
|
103
|
+
<HeaderIcon />
|
|
104
|
+
<HeaderLogo />
|
|
82
105
|
</RouteLink>
|
|
83
106
|
|
|
84
107
|
{/* Right cluster wrapper. Grows from a zero basis so its width
|
|
@@ -96,7 +119,7 @@ export function CiderpressHeader(props: CiderpressHeaderProps): React.ReactEleme
|
|
|
96
119
|
<CiderpressNavMenu items={props.navItems} />
|
|
97
120
|
</div>
|
|
98
121
|
|
|
99
|
-
<NavDivider />
|
|
122
|
+
{hasTrailingCluster && <NavDivider />}
|
|
100
123
|
<VariantToggle />
|
|
101
124
|
<CiderpressNavSocialLinks links={props.socialLinks} />
|
|
102
125
|
{props.topbarCta !== undefined && (
|
|
@@ -6,13 +6,15 @@ import { Icon } from '../shared/icon.tsx'
|
|
|
6
6
|
import './ciderpress-nav-social-links.css'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Single social-link entry — matches the
|
|
10
|
-
* `
|
|
9
|
+
* Single social-link entry — matches the serialised `socials` shape
|
|
10
|
+
* written into `themeConfig.socialLinks` by `packages/ui/src/config.ts`.
|
|
11
|
+
*
|
|
12
|
+
* The Rspress `mode` / `content` discriminator was killed in the
|
|
13
|
+
* `rc.4` API overhaul; every link is now a plain anchor.
|
|
11
14
|
*/
|
|
12
15
|
export interface CiderpressSocialLink {
|
|
13
16
|
readonly icon: string
|
|
14
|
-
readonly
|
|
15
|
-
readonly content: string
|
|
17
|
+
readonly url: string
|
|
16
18
|
readonly label?: string
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -39,7 +41,7 @@ const ICON_MAP: Readonly<Record<string, string>> = Object.freeze({
|
|
|
39
41
|
* Renders the configured social links as a cluster of icon buttons in
|
|
40
42
|
* the topbar. Returns `null` when no links are configured.
|
|
41
43
|
*
|
|
42
|
-
* @param props - List of social links from `site.
|
|
44
|
+
* @param props - List of social links from `site.socials`
|
|
43
45
|
* @returns Cluster of icon links, or `null`
|
|
44
46
|
*/
|
|
45
47
|
export function CiderpressNavSocialLinks(
|
|
@@ -50,7 +52,7 @@ export function CiderpressNavSocialLinks(
|
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
const safeLinks = props.links
|
|
53
|
-
.map((link) => ({ link, href: safeUrl(link.
|
|
55
|
+
.map((link) => ({ link, href: safeUrl(link.url) }))
|
|
54
56
|
.filter((entry): entry is { readonly link: CiderpressSocialLink; readonly href: string } => {
|
|
55
57
|
if (entry.href === null) {
|
|
56
58
|
return false
|
|
@@ -66,7 +68,7 @@ export function CiderpressNavSocialLinks(
|
|
|
66
68
|
<div className="cp-nav-social">
|
|
67
69
|
{safeLinks.map(({ link, href }) => (
|
|
68
70
|
<a
|
|
69
|
-
key={link.
|
|
71
|
+
key={link.url}
|
|
70
72
|
href={href}
|
|
71
73
|
target="_blank"
|
|
72
74
|
rel="noopener noreferrer"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { SiteConfig } from '@ciderpress/config'
|
|
2
1
|
import { match, P } from 'massaman/match'
|
|
3
2
|
import type React from 'react'
|
|
4
3
|
|
|
4
|
+
import type { CiderpressSiteBlock } from '../../hooks/use-ciderpress'
|
|
5
5
|
import { useCiderpress } from '../../hooks/use-ciderpress'
|
|
6
6
|
|
|
7
7
|
import './floating-branch-indicator.css'
|
|
@@ -15,7 +15,7 @@ declare const __CIDERPRESS_GIT_BRANCH__: string | undefined
|
|
|
15
15
|
* the branch name. Click anywhere on the pill opens the branch on GitHub.
|
|
16
16
|
*
|
|
17
17
|
* Hidden when on default branches (`main` / `master`), when the
|
|
18
|
-
* build-time branch global is undefined, or when `
|
|
18
|
+
* build-time branch global is undefined, or when `editLink.repo` is
|
|
19
19
|
* not configured.
|
|
20
20
|
*
|
|
21
21
|
* @returns React element or null when hidden
|
|
@@ -69,7 +69,7 @@ function resolveBranch(): string {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
* Build a GitHub `/tree/<branch>` URL from the configured `
|
|
72
|
+
* Build a GitHub `/tree/<branch>` URL from the configured `editLink.repo`.
|
|
73
73
|
*
|
|
74
74
|
* Accepts either a full URL (used as-is) or an `org/repo` slug
|
|
75
75
|
* (prefixed with `https://github.com/`). Returns `null` when `repo` is
|
|
@@ -91,14 +91,14 @@ function buildBranchHref(params: {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
|
-
* Pull the configured edit-repo slug off the site
|
|
95
|
-
* with explicit null checks rather than optional chaining.
|
|
94
|
+
* Pull the configured edit-repo slug off the serialised site block,
|
|
95
|
+
* expressed with explicit null checks rather than optional chaining.
|
|
96
96
|
*
|
|
97
97
|
* @private
|
|
98
|
-
* @param site - The
|
|
98
|
+
* @param site - The runtime site block (may be undefined).
|
|
99
99
|
* @returns The configured `edit.repo` value or `undefined`.
|
|
100
100
|
*/
|
|
101
|
-
function resolveRepo(site:
|
|
101
|
+
function resolveRepo(site: CiderpressSiteBlock | undefined): string | undefined {
|
|
102
102
|
if (site === undefined) {
|
|
103
103
|
return undefined
|
|
104
104
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small brand chip rendered immediately before `<HeaderLogo />` inside
|
|
3
|
+
* `cp-header-logo`. Sized to match `--cp-header-icon-size` (default
|
|
4
|
+
* 28px — same as the logo height token) so the two slots line up
|
|
5
|
+
* vertically by default. Themes can override either token to break
|
|
6
|
+
* the lockstep.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
.cp-header-icon {
|
|
10
|
+
display: inline-flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
flex-shrink: 0;
|
|
13
|
+
height: var(--cp-header-icon-size, var(--cp-header-logo-height, 28px));
|
|
14
|
+
width: var(--cp-header-icon-size, var(--cp-header-logo-height, 28px));
|
|
15
|
+
margin-right: var(--cp-header-icon-gap, 8px);
|
|
16
|
+
color: var(--cp-header-icon-color, currentColor);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.cp-header-icon > svg,
|
|
20
|
+
.cp-header-icon__img {
|
|
21
|
+
height: 100%;
|
|
22
|
+
width: 100%;
|
|
23
|
+
display: block;
|
|
24
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/* oxlint-disable no-ternary -- raw-copied file; relaxed rules per packages/ui/CLAUDE.md */
|
|
2
|
+
import type { CiderpressConfig, IconConfig, IconImage } from '@ciderpress/config'
|
|
3
|
+
// oxlint-disable-next-line import/no-unresolved -- alias provided by createRspressConfig's resolve.alias
|
|
4
|
+
import userConfigModule from '@ciderpress/internal/user-config'
|
|
5
|
+
import { match } from 'massaman/match'
|
|
6
|
+
import type React from 'react'
|
|
7
|
+
|
|
8
|
+
import { Icon } from '../shared/icon'
|
|
9
|
+
|
|
10
|
+
import './header-icon.css'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Small brand chip rendered before `<HeaderLogo />` inside `cp-header-logo`.
|
|
14
|
+
*
|
|
15
|
+
* Reads `userConfig.brand.icon` from the bundled user config and routes:
|
|
16
|
+
* - Iconify id (`"devicon:react"`) → `<Icon>` component.
|
|
17
|
+
* - `{ id, color }` → `<Icon>` with inline color.
|
|
18
|
+
* - `{ src, alt }` → `<img>` with the user's asset.
|
|
19
|
+
* - missing → null (slot collapses, `<HeaderLogo />` stretches).
|
|
20
|
+
*
|
|
21
|
+
* Pairs with `<HeaderLogo />` for the canonical two-slot brand identity
|
|
22
|
+
* (icon chip + wordmark logo). Sites with only one of the two get the
|
|
23
|
+
* lone element rendered.
|
|
24
|
+
*
|
|
25
|
+
* @returns Branded icon chip ready to drop inside `cp-header-logo`, or null
|
|
26
|
+
*/
|
|
27
|
+
export function HeaderIcon(): React.ReactElement | null {
|
|
28
|
+
const icon = readIconConfig(userConfigModule)
|
|
29
|
+
if (icon === undefined) {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
return match(resolveTopbar(icon))
|
|
33
|
+
.with({ kind: 'iconify' }, (r) => (
|
|
34
|
+
<span className="cp-header-icon">
|
|
35
|
+
<Icon icon={r.id} style={inlineColor(r.color)} />
|
|
36
|
+
</span>
|
|
37
|
+
))
|
|
38
|
+
.with({ kind: 'image' }, (r) => (
|
|
39
|
+
<span className="cp-header-icon">
|
|
40
|
+
<img src={r.src} alt={r.alt} className="cp-header-icon__img" />
|
|
41
|
+
</span>
|
|
42
|
+
))
|
|
43
|
+
.exhaustive()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { HeaderIcon as default }
|
|
47
|
+
|
|
48
|
+
type TopbarIcon =
|
|
49
|
+
| { readonly kind: 'iconify'; readonly id: string; readonly color: string | undefined }
|
|
50
|
+
| { readonly kind: 'image'; readonly src: string; readonly alt: string }
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract the `brand.icon` field from the bundled user config module.
|
|
54
|
+
*
|
|
55
|
+
* @private
|
|
56
|
+
* @param mod - Module imported from `@ciderpress/internal/user-config`
|
|
57
|
+
* @returns The `brand.icon` value or `undefined` when none is configured
|
|
58
|
+
*/
|
|
59
|
+
function readIconConfig(mod: unknown): IconConfig | undefined {
|
|
60
|
+
if (mod === null || mod === undefined) {
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
const asRecord = mod as Record<string, unknown>
|
|
64
|
+
const candidate =
|
|
65
|
+
asRecord.default !== null && asRecord.default !== undefined
|
|
66
|
+
? (asRecord.default as Partial<CiderpressConfig>)
|
|
67
|
+
: (mod as Partial<CiderpressConfig>)
|
|
68
|
+
const brand = candidate.brand
|
|
69
|
+
if (brand === undefined) {
|
|
70
|
+
return undefined
|
|
71
|
+
}
|
|
72
|
+
return brand.icon
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Discriminate `IconConfig` into a render-ready shape. Iconify strings get
|
|
77
|
+
* `color: undefined` (driven by ambient CSS); image objects supply alt
|
|
78
|
+
* defaulted to empty string per the underlying type.
|
|
79
|
+
*
|
|
80
|
+
* @private
|
|
81
|
+
* @param icon - Validated `IconConfig` value
|
|
82
|
+
* @returns Discriminated render shape
|
|
83
|
+
*/
|
|
84
|
+
function resolveTopbar(icon: IconConfig): TopbarIcon {
|
|
85
|
+
if (typeof icon === 'string') {
|
|
86
|
+
return { kind: 'iconify', id: icon, color: undefined }
|
|
87
|
+
}
|
|
88
|
+
if (isImageIcon(icon)) {
|
|
89
|
+
return { kind: 'image', src: icon.src, alt: icon.alt ?? '' }
|
|
90
|
+
}
|
|
91
|
+
return { kind: 'iconify', id: icon.id, color: icon.color }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build an inline-style object that sets the chip's `color` when the user
|
|
96
|
+
* supplied an explicit palette colour. Iconify renders fills via
|
|
97
|
+
* `currentColor` for monochrome icons, so this is enough to retint them.
|
|
98
|
+
*
|
|
99
|
+
* @private
|
|
100
|
+
* @param color - Optional palette colour token
|
|
101
|
+
* @returns Inline style object or undefined
|
|
102
|
+
*/
|
|
103
|
+
function inlineColor(color: string | undefined): React.CSSProperties | undefined {
|
|
104
|
+
if (color === undefined) {
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
|
107
|
+
return { color }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Type guard for the image-form `IconConfig` object.
|
|
112
|
+
*
|
|
113
|
+
* @private
|
|
114
|
+
* @param icon - Non-string icon config value
|
|
115
|
+
* @returns True when the object carries `src` (image form)
|
|
116
|
+
*/
|
|
117
|
+
function isImageIcon(icon: Exclude<IconConfig, string>): icon is IconImage {
|
|
118
|
+
return 'src' in icon && typeof icon.src === 'string'
|
|
119
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sizing for the brand mark rendered inside `cp-header-logo` by
|
|
3
|
+
* `<HeaderLogo />`.
|
|
4
|
+
*
|
|
5
|
+
* The parent `.cp-header-logo` already sets `--cp-header-logo-height`
|
|
6
|
+
* (default 28px) and `width: auto` for inline SVGs. Apply matching
|
|
7
|
+
* rules to image marks so a user `logo: '/logo.svg'` lines up exactly
|
|
8
|
+
* with the SVG wordmark height — no extra config needed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
.cp-header-logo__img,
|
|
12
|
+
.cp-header-logo__mark {
|
|
13
|
+
height: var(--cp-header-logo-height, 28px);
|
|
14
|
+
width: auto;
|
|
15
|
+
display: block;
|
|
16
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/* oxlint-disable no-ternary -- raw-copied file; relaxed rules per packages/ui/CLAUDE.md */
|
|
2
|
+
import type { CiderpressConfig, LogoFn, LogoImage } from '@ciderpress/config'
|
|
3
|
+
// oxlint-disable-next-line import/no-unresolved -- alias provided by createRspressConfig's resolve.alias
|
|
4
|
+
import userConfigModule from '@ciderpress/internal/user-config'
|
|
5
|
+
import { BRAND_COLORS, DEFAULT_THEME_NAME } from '@ciderpress/theme'
|
|
6
|
+
import React, { useEffect, useState } from 'react'
|
|
7
|
+
|
|
8
|
+
import { withMountBase } from '../../lib/with-mount-base.ts'
|
|
9
|
+
|
|
10
|
+
import './header-logo.css'
|
|
11
|
+
|
|
12
|
+
interface ThemeContext {
|
|
13
|
+
readonly name: string
|
|
14
|
+
readonly variant: 'light' | 'dark'
|
|
15
|
+
readonly isDark: boolean
|
|
16
|
+
readonly colors: {
|
|
17
|
+
readonly brand: string
|
|
18
|
+
readonly brandHover: string
|
|
19
|
+
readonly brandSoft: string
|
|
20
|
+
readonly bg: string
|
|
21
|
+
readonly text: string
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const COLOR_VARS = Object.freeze({
|
|
26
|
+
brand: '--rp-c-brand',
|
|
27
|
+
brandHover: '--rp-c-brand-dark',
|
|
28
|
+
brandSoft: '--rp-c-brand-tint',
|
|
29
|
+
bg: '--rp-c-bg',
|
|
30
|
+
text: '--rp-c-text-1',
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const DEFAULT_BRAND = BRAND_COLORS[DEFAULT_THEME_NAME]
|
|
34
|
+
const FALLBACK_COLORS = Object.freeze({
|
|
35
|
+
brand: DEFAULT_BRAND.primary,
|
|
36
|
+
brandHover: DEFAULT_BRAND.hover,
|
|
37
|
+
brandSoft: DEFAULT_BRAND.soft,
|
|
38
|
+
bg: '#ffffff',
|
|
39
|
+
text: '#1f2937',
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Brand mark rendered inside `<RouteLink className="cp-header-logo">`
|
|
44
|
+
* by `<CiderpressHeader />`.
|
|
45
|
+
*
|
|
46
|
+
* Reads `userConfig.brand.logo` from the bundled user config (same
|
|
47
|
+
* `@ciderpress/internal/user-config` alias `NavLogo` uses) and renders:
|
|
48
|
+
*
|
|
49
|
+
* - `string` → `<img src={...}>` pointed at the user's asset path.
|
|
50
|
+
* - `LogoFn` → call with the live theme context. `LogoImage` returns
|
|
51
|
+
* render as `<img>`; React node returns render as-is. Re-renders
|
|
52
|
+
* when `data-cp-theme` / `data-cp-variant` flips so theme-aware
|
|
53
|
+
* logos retint without a reload.
|
|
54
|
+
* - missing → the framework's themed `<CiderpressLogo />` wordmark.
|
|
55
|
+
*
|
|
56
|
+
* Replaces the prior approach where the header was hardcoded to
|
|
57
|
+
* `<CiderpressLogo />` and the user's logo was forwarded only to
|
|
58
|
+
* Rspress's nav slot — which renders at 1×1 on landing pages.
|
|
59
|
+
*
|
|
60
|
+
* @returns Branded logo element ready to drop inside `cp-header-logo`
|
|
61
|
+
*/
|
|
62
|
+
export function HeaderLogo(): React.ReactElement | null {
|
|
63
|
+
const logoConfig = readLogoConfig(userConfigModule)
|
|
64
|
+
const [themeContext, setThemeContext] = useState<ThemeContext | null>(null)
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (typeof logoConfig !== 'function') {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
const html = globalThis.document.documentElement
|
|
71
|
+
setThemeContext(readThemeContext(html))
|
|
72
|
+
|
|
73
|
+
const observer = new MutationObserver(() => {
|
|
74
|
+
setThemeContext(readThemeContext(html))
|
|
75
|
+
})
|
|
76
|
+
observer.observe(html, {
|
|
77
|
+
attributes: true,
|
|
78
|
+
attributeFilter: ['data-cp-theme', 'data-cp-variant', 'class', 'style'],
|
|
79
|
+
})
|
|
80
|
+
return () => observer.disconnect()
|
|
81
|
+
}, [logoConfig])
|
|
82
|
+
|
|
83
|
+
if (logoConfig === undefined) {
|
|
84
|
+
// Default: the auto-generated `/logo.svg` written to the public dir
|
|
85
|
+
// by the banner module at sync time (derived from `config.title`).
|
|
86
|
+
// Sites that committed their own `public/logo.svg` already win here.
|
|
87
|
+
// The `<CiderpressLogo />` framework wordmark is opt-in via
|
|
88
|
+
// `logo: ({ theme }) => <CiderpressLogo />`.
|
|
89
|
+
return <img src={withMountBase('/logo.svg')} alt="" className="cp-header-logo__img" />
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (typeof logoConfig === 'string') {
|
|
93
|
+
return <img src={withMountBase(logoConfig)} alt="" className="cp-header-logo__img" />
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (themeContext === null) {
|
|
97
|
+
// Function-form logo: render nothing for a frame instead of flashing
|
|
98
|
+
// the ciderpress wordmark while themeContext resolves. The user's
|
|
99
|
+
// logo appears as soon as the layout effect runs.
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const result = logoConfig({ theme: themeContext })
|
|
104
|
+
if (isLogoImage(result)) {
|
|
105
|
+
return (
|
|
106
|
+
<img
|
|
107
|
+
src={withMountBase(result.src)}
|
|
108
|
+
alt={result.alt ?? ''}
|
|
109
|
+
width={result.width}
|
|
110
|
+
height={result.height}
|
|
111
|
+
className="cp-header-logo__img"
|
|
112
|
+
/>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
return <>{result as React.ReactNode}</>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export { HeaderLogo as default }
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Extract the `logo` field from the bundled user config module.
|
|
122
|
+
*
|
|
123
|
+
* @private
|
|
124
|
+
* @param mod - Module imported from `@ciderpress/internal/user-config`
|
|
125
|
+
* @returns The `logo` value or `undefined` when none is configured
|
|
126
|
+
*/
|
|
127
|
+
function readLogoConfig(mod: unknown): string | LogoFn | undefined {
|
|
128
|
+
if (mod === null || mod === undefined) {
|
|
129
|
+
return undefined
|
|
130
|
+
}
|
|
131
|
+
const asRecord = mod as Record<string, unknown>
|
|
132
|
+
const candidate =
|
|
133
|
+
asRecord.default !== null && asRecord.default !== undefined
|
|
134
|
+
? (asRecord.default as Partial<CiderpressConfig>)
|
|
135
|
+
: (mod as Partial<CiderpressConfig>)
|
|
136
|
+
const brand = candidate.brand
|
|
137
|
+
if (brand === undefined) {
|
|
138
|
+
return undefined
|
|
139
|
+
}
|
|
140
|
+
const { logo } = brand
|
|
141
|
+
if (typeof logo === 'string') {
|
|
142
|
+
return logo
|
|
143
|
+
}
|
|
144
|
+
if (typeof logo === 'function') {
|
|
145
|
+
return logo
|
|
146
|
+
}
|
|
147
|
+
return undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Type guard distinguishing a `LogoImage` object from a React element.
|
|
152
|
+
*
|
|
153
|
+
* @private
|
|
154
|
+
* @param value - Return value from a `LogoFn`
|
|
155
|
+
* @returns True when `value` should be spread onto an `<img>` element
|
|
156
|
+
*/
|
|
157
|
+
function isLogoImage(value: unknown): value is LogoImage {
|
|
158
|
+
if (value === null || typeof value !== 'object') {
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
const obj = value as Record<string, unknown>
|
|
162
|
+
if ('$$typeof' in obj) {
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
return typeof obj.src === 'string'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Build the live `ThemeContext` passed to a function-form `logo`.
|
|
170
|
+
*
|
|
171
|
+
* @private
|
|
172
|
+
* @param html - Document root element
|
|
173
|
+
* @returns Live theme context snapshot
|
|
174
|
+
*/
|
|
175
|
+
function readThemeContext(html: HTMLElement): ThemeContext {
|
|
176
|
+
const variant: 'light' | 'dark' = html.dataset.cpVariant === 'light' ? 'light' : 'dark'
|
|
177
|
+
const name = typeof html.dataset.cpTheme === 'string' ? html.dataset.cpTheme : DEFAULT_THEME_NAME
|
|
178
|
+
|
|
179
|
+
const styles = globalThis.window.getComputedStyle(html)
|
|
180
|
+
function read(cssVar: string, fallback: string): string {
|
|
181
|
+
const raw = styles.getPropertyValue(cssVar).trim()
|
|
182
|
+
if (raw.length === 0) {
|
|
183
|
+
return fallback
|
|
184
|
+
}
|
|
185
|
+
return raw
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
name,
|
|
190
|
+
variant,
|
|
191
|
+
isDark: variant === 'dark',
|
|
192
|
+
colors: {
|
|
193
|
+
brand: read(COLOR_VARS.brand, FALLBACK_COLORS.brand),
|
|
194
|
+
brandHover: read(COLOR_VARS.brandHover, FALLBACK_COLORS.brandHover),
|
|
195
|
+
brandSoft: read(COLOR_VARS.brandSoft, FALLBACK_COLORS.brandSoft),
|
|
196
|
+
bg: read(COLOR_VARS.bg, FALLBACK_COLORS.bg),
|
|
197
|
+
text: read(COLOR_VARS.text, FALLBACK_COLORS.text),
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|