Skip to content

Commit c846692

Browse files
Mpdreamzclaude
andauthored
Add white-label branding support for isolated builds (#3159)
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent b31505c commit c846692

14 files changed

Lines changed: 386 additions & 77 deletions

File tree

src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ public record ConfigurationFile
6868
/// </summary>
6969
public HashSet<HintType> SuppressDiagnostics { get; } = [];
7070

71+
/// <summary>
72+
/// White-label branding overrides. When non-null, all Elastic-specific chrome is suppressed.
73+
/// </summary>
74+
public BrandingConfiguration? Branding { get; private set; }
75+
7176
/// This is a documentation set not linked to by assembler.
7277
/// Setting this to true relaxes a few restrictions such as mixing toc references with file and folder reference
7378
public bool DevelopmentDocs { get; }
@@ -248,13 +253,21 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte
248253
.ToHashSet()!;
249254
}
250255

256+
// Process branding with validation
257+
if (docSetFile.Branding is not null)
258+
Branding = ValidateBranding(docSetFile.Branding, context);
259+
251260
// Process features
252261
_features = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
253262
if (docSetFile.Features.PrimaryNav.HasValue)
254263
_features["primary-nav"] = docSetFile.Features.PrimaryNav.Value;
255264
if (docSetFile.Features.DisableGithubEditLink.HasValue)
256265
_features["disable-github-edit-link"] = docSetFile.Features.DisableGithubEditLink.Value;
257266

267+
// primary-nav requires the Elastic global navigation which is not available for white-label builds
268+
if (Branding is not null && docSetFile.Features.PrimaryNav is true)
269+
context.EmitError(context.ConfigurationPath, "'features.primary-nav' cannot be used together with 'branding': the primary nav requires Elastic global navigation.");
270+
258271
// Add version substitutions
259272
foreach (var (id, system) in versionsConfig.VersioningSystems)
260273
{
@@ -283,6 +296,56 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte
283296
}
284297
}
285298

299+
private static readonly HashSet<string> AllowedImageExtensions =
300+
[".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"];
301+
302+
private static BrandingConfiguration ValidateBranding(BrandingConfiguration branding, IDocumentationSetContext context)
303+
{
304+
branding.Icon = ValidateBrandingImage(branding.Icon, "branding.icon", context);
305+
branding.OgImage = ValidateBrandingImage(branding.OgImage, "branding.og-image", context);
306+
return branding;
307+
}
308+
309+
private static string? ValidateBrandingImage(string? imagePath, string fieldName, IDocumentationSetContext context)
310+
{
311+
if (string.IsNullOrEmpty(imagePath))
312+
return null;
313+
314+
var ext = Path.GetExtension(imagePath).ToLowerInvariant();
315+
if (!AllowedImageExtensions.Contains(ext))
316+
{
317+
context.EmitError(context.ConfigurationPath,
318+
$"'{fieldName}' has unsupported extension '{ext}'. Allowed: {string.Join(", ", AllowedImageExtensions)}");
319+
return null;
320+
}
321+
322+
var resolved = context.ReadFileSystem.FileInfo.New(
323+
Path.GetFullPath(Path.Join(context.DocumentationSourceDirectory.FullName, imagePath))
324+
);
325+
326+
if (!resolved.IsSubPathOf(context.DocumentationSourceDirectory))
327+
{
328+
context.EmitError(context.ConfigurationPath,
329+
$"'{fieldName}' path '{imagePath}' escapes the documentation source directory.");
330+
return null;
331+
}
332+
333+
if (resolved.LinkTarget is not null)
334+
{
335+
context.EmitError(context.ConfigurationPath,
336+
$"'{fieldName}' path '{imagePath}' is a symbolic link, which is not allowed for branding images.");
337+
return null;
338+
}
339+
340+
if (!resolved.Exists)
341+
{
342+
context.EmitError(context.ConfigurationPath, $"'{fieldName}' file '{imagePath}' does not exist.");
343+
return null;
344+
}
345+
346+
return imagePath;
347+
}
348+
286349
private static CrossLinkEntry? ParseCrossLinkEntry(string raw, DocSetRegistry docsetRegistry, IFileInfo configPath, IDocumentationContext context)
287350
{
288351
DocSetRegistry entryRegistry;

src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ public class DocumentationSetFile : TableOfContentsFile
6666
[YamlMember(Alias = "codex")]
6767
public CodexDocSetMetadata? Codex { get; set; }
6868

69+
/// <summary>
70+
/// Optional white-label branding overrides. When present, all Elastic-specific chrome is suppressed.
71+
/// </summary>
72+
[YamlMember(Alias = "branding")]
73+
public BrandingConfiguration? Branding { get; set; }
74+
6975
public static FileRef[] GetFileRefs(ITableOfContentsItem item)
7076
{
7177
if (item is FileRef fileRef)
@@ -650,3 +656,23 @@ public class CodexDocSetMetadata
650656
[YamlMember(Alias = "group")]
651657
public string? Group { get; set; }
652658
}
659+
660+
/// <summary>
661+
/// White-label branding overrides for isolated builds. Presence of this section removes all Elastic-specific chrome.
662+
/// All image paths are relative to the directory containing <c>docset.yml</c>.
663+
/// </summary>
664+
[YamlSerializable]
665+
public class BrandingConfiguration
666+
{
667+
/// <summary>Path to the site icon image, relative to the docs source directory.</summary>
668+
[YamlMember(Alias = "icon")]
669+
public string? Icon { get; set; }
670+
671+
/// <summary>CSS colour value for the header background. Defaults to <c>#000000</c> when not specified.</summary>
672+
[YamlMember(Alias = "header-bg", ApplyNamingConventions = false)]
673+
public string? HeaderBg { get; set; }
674+
675+
/// <summary>Path to the Open Graph image, relative to the docs source directory.</summary>
676+
[YamlMember(Alias = "og-image", ApplyNamingConventions = false)]
677+
public string? OgImage { get; set; }
678+
}

src/Elastic.Documentation.Site/Assets/web-components/Header/DeploymentInfo.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,8 @@ function getDeploymentLinks(
275275
gitCommit: string,
276276
githubRef?: string
277277
): { ref?: string; branch: string; commit: string; repository: string } {
278-
const repo = githubRepository.startsWith('elastic/')
278+
// Backend passes full org/repo; fallback only fires for bare names (shouldn't occur)
279+
const repo = githubRepository.includes('/')
279280
? githubRepository
280281
: `elastic/${githubRepository}`
281282
const base = `${GITHUB_BASE}/${repo}`

src/Elastic.Documentation.Site/Assets/web-components/Header/Header.tsx

Lines changed: 102 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ interface Props {
2323
githubRef?: string
2424
/** When true, deployment info is hidden (not relevant in air-gapped environments). */
2525
airGapped?: boolean
26+
/**
27+
* When true the docset has `branding` configured: suppresses the Elastic logo and
28+
* uses a custom background. The Razor view always passes this explicitly so the
29+
* component does not have to infer branding state from other optional props.
30+
*/
31+
branded?: boolean
32+
/** Custom header background CSS colour. Only used when branded=true; defaults to #000000. */
33+
headerBg?: string
34+
/** Custom icon image URL. When set (and branded=true), renders an <img> instead of the title text. */
35+
iconSrc?: string
2636
}
2737

2838
export const Header = ({
@@ -34,56 +44,108 @@ export const Header = ({
3444
gitCommit,
3545
githubRef,
3646
airGapped = false,
47+
branded = false,
48+
headerBg,
49+
iconSrc,
3750
}: Props) => {
3851
const { euiTheme } = useEuiTheme()
3952
const containerRef = useRef<HTMLSpanElement>(null)
4053
useHtmxContainer(containerRef)
4154

55+
const bgColor = branded ? headerBg || '#000000' : euiTheme.colors.primary
56+
57+
const logoSection = branded ? (
58+
iconSrc ? (
59+
<span ref={containerRef}>
60+
<a
61+
href={logoHref}
62+
css={css`
63+
display: inline-flex;
64+
align-items: center;
65+
gap: ${euiTheme.size.s};
66+
color: var(--color-white);
67+
text-decoration: none;
68+
padding: ${euiTheme.size.s};
69+
`}
70+
>
71+
<img
72+
src={iconSrc}
73+
alt={title}
74+
css={css`
75+
height: 24px;
76+
width: auto;
77+
`}
78+
/>
79+
{title}
80+
</a>
81+
</span>
82+
) : (
83+
// Branding configured but no icon — title text only, no Elastic logo
84+
<span ref={containerRef}>
85+
<a
86+
href={logoHref}
87+
css={css`
88+
display: inline-flex;
89+
align-items: center;
90+
color: var(--color-white);
91+
text-decoration: none;
92+
padding: ${euiTheme.size.s};
93+
font-weight: ${euiTheme.font.weight.bold};
94+
`}
95+
>
96+
{title}
97+
</a>
98+
</span>
99+
)
100+
) : (
101+
// Default: Elastic-branded logo (light-mode styling)
102+
<span ref={containerRef}>
103+
<EuiHeaderLogo
104+
href={logoHref}
105+
css={css`
106+
padding-block: 7px;
107+
height: auto;
108+
line-height: normal;
109+
border-radius: ${euiTheme.border.radius.small};
110+
&:hover {
111+
background: rgba(0, 0, 0, 0.06) !important;
112+
}
113+
& > span {
114+
color: ${euiTheme.colors.textInk};
115+
}
116+
`}
117+
>
118+
{title}
119+
</EuiHeaderLogo>
120+
</span>
121+
)
122+
42123
return (
43124
<EuiProvider
44125
colorMode="light"
45126
globalStyles={false}
46127
utilityClasses={false}
47128
>
48129
<EuiHeader
49-
css={css`
50-
background: linear-gradient(
51-
to bottom,
52-
#ffffff 0%,
53-
#f5f7fa 100%
54-
);
55-
border-bottom: 1px solid ${euiTheme.colors.lightShade};
56-
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.07);
57-
`}
130+
css={
131+
branded
132+
? css`
133+
background-color: ${bgColor};
134+
`
135+
: css`
136+
background: linear-gradient(
137+
to bottom,
138+
#ffffff 0%,
139+
#f5f7fa 100%
140+
);
141+
border-bottom: 1px solid
142+
${euiTheme.colors.lightShade};
143+
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.07);
144+
`
145+
}
58146
sections={[
59147
{
60-
items: [
61-
<span ref={containerRef}>
62-
<EuiHeaderLogo
63-
href={logoHref}
64-
css={css`
65-
padding-block: 7px;
66-
height: auto;
67-
line-height: normal;
68-
border-radius: ${euiTheme.border.radius
69-
.small};
70-
&:hover {
71-
background: rgba(
72-
0,
73-
0,
74-
0,
75-
0.06
76-
) !important;
77-
}
78-
& > span {
79-
color: ${euiTheme.colors.textInk};
80-
}
81-
`}
82-
>
83-
{title}
84-
</EuiHeaderLogo>
85-
</span>,
86-
],
148+
items: [logoSection],
87149
},
88150
...(!airGapped
89151
? [
@@ -114,9 +176,7 @@ export const Header = ({
114176
<DeploymentInfo
115177
gitBranch={gitBranch}
116178
gitCommit={gitCommit}
117-
githubRepository={
118-
'elastic/' + githubRepository
119-
}
179+
githubRepository={githubRepository}
120180
githubRef={githubRef}
121181
/>,
122182
],
@@ -141,6 +201,9 @@ customElements.define(
141201
gitCommit: 'string',
142202
githubRef: 'string',
143203
airGapped: 'boolean',
204+
branded: 'boolean',
205+
headerBg: 'string',
206+
iconSrc: 'string',
144207
},
145208
})
146209
)

src/Elastic.Documentation.Site/Layout/_Head.cshtml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,15 @@
4747
<meta property="og:type" content="website"/>
4848
<meta property="og:title" content="@Model.Title"/>
4949
<meta property="og:description" content="@Model.Description"/>
50-
<meta property="og:image" content="https://www.elastic.co/static-res/images/social_media_default.png"/>
51-
<meta property="og:image:alt" content="Elastic | The Search AI Company"/>
50+
@if (Model.Branding is null)
51+
{
52+
<meta property="og:image" content="https://www.elastic.co/static-res/images/social_media_default.png"/>
53+
<meta property="og:image:alt" content="Elastic | The Search AI Company"/>
54+
}
55+
else if (Model.BrandingOgImageStaticPath is { } ogPath)
56+
{
57+
<meta property="og:image" content="@(Model.CanonicalBaseUrl is not null ? new Uri(Model.CanonicalBaseUrl, ogPath).ToString() : ogPath)"/>
58+
}
5259
@if (!string.IsNullOrEmpty(Model.CanonicalUrl))
5360
{
5461
<meta property="og:url" content="@Model.CanonicalUrl" />

0 commit comments

Comments
 (0)