Skip to content

Commit d96f845

Browse files
authored
Merge pull request #22 from nexlabstudio/feat/custom-css-file
feat: support custom CSS file in theme configuration
2 parents 2afd154 + 2ccf90b commit d96f845

File tree

8 files changed

+350
-11
lines changed

8 files changed

+350
-11
lines changed

docs/config/theme.md

Lines changed: 111 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,9 @@ theme:
142142

143143
## Custom CSS
144144

145-
Add custom styles to your site:
145+
Add custom styles to your site inline or via a CSS file.
146+
147+
### Inline CSS
146148

147149
```yaml
148150
theme:
@@ -157,6 +159,26 @@ theme:
157159
}
158160
```
159161

162+
### CSS File
163+
164+
Point to an external CSS file for larger customizations:
165+
166+
```yaml
167+
theme:
168+
custom:
169+
cssFile: styles/custom.css
170+
```
171+
172+
You can use both together — the file is loaded first, then inline CSS is appended:
173+
174+
```yaml
175+
theme:
176+
custom:
177+
cssFile: styles/base.css
178+
css: |
179+
.header { border-bottom: 2px solid var(--color-primary); }
180+
```
181+
160182
### CSS Variables
161183

162184
Stardust exposes CSS variables you can override:
@@ -165,19 +187,103 @@ Stardust exposes CSS variables you can override:
165187
:root {
166188
/* Colors */
167189
--color-primary: #6366f1;
168-
--color-background: #ffffff;
190+
--color-bg: #ffffff;
191+
--color-bg-secondary: #f8fafc;
169192
--color-text: #1e293b;
170-
--border-color: #e2e8f0;
193+
--color-text-secondary: #64748b;
194+
--color-border: #e2e8f0;
171195
172196
/* Fonts */
173-
--font-sans: 'Inter', sans-serif;
197+
--font-sans: 'Inter', system-ui, sans-serif;
174198
--font-mono: 'JetBrains Mono', monospace;
175199
176200
/* Spacing */
177201
--radius: 8px;
178202
}
179203
```
180204

205+
In dark mode (`.dark`), the color variables are automatically updated. You can target dark mode specifically:
206+
207+
```css
208+
.dark {
209+
--color-bg: #0f172a;
210+
--color-bg-secondary: #1e293b;
211+
--color-text: #e2e8f0;
212+
--color-text-secondary: #94a3b8;
213+
--color-border: #334155;
214+
}
215+
```
216+
217+
### CSS Classes Reference
218+
219+
Use your browser's DevTools to inspect elements and discover classes. Here are the main targetable classes:
220+
221+
**Layout**
222+
223+
| Class | Element |
224+
|-------|---------|
225+
| `.header` | Top navigation bar |
226+
| `.header-inner` | Header content container |
227+
| `.logo` | Logo link |
228+
| `.logo-text` | Site name text |
229+
| `.nav` | Desktop navigation links |
230+
| `.header-actions` | Search, theme toggle, social links area |
231+
| `.sidebar` | Left sidebar navigation |
232+
| `.content` | Main content area |
233+
| `.toc` | Table of contents (right side) |
234+
| `.footer` | Page footer |
235+
236+
**Sidebar**
237+
238+
| Class | Element |
239+
|-------|---------|
240+
| `.sidebar-group` | A sidebar section |
241+
| `.sidebar-group-title` | Group heading (clickable when collapsible) |
242+
| `.sidebar-group-label` | Group name text |
243+
| `.sidebar-group-icon` | Icon next to group name |
244+
| `.sidebar-links` | List of links in a group |
245+
| `.sidebar-link` | Individual sidebar link |
246+
| `.sidebar-link.active` | Currently active page link |
247+
248+
**Content**
249+
250+
| Class | Element |
251+
|-------|---------|
252+
| `.prose` | Markdown content wrapper |
253+
| `.code-block` | Fenced code block container |
254+
| `.code-header` | Code block header (language label + copy button) |
255+
| `.copy-button` | Code copy button |
256+
257+
**Components**
258+
259+
| Class | Element |
260+
|-------|---------|
261+
| `.callout` | Callout/admonition blocks (Tip, Warning, etc.) |
262+
| `.tabs` | Tab container |
263+
| `.code-group` | Code group (tabbed code blocks) |
264+
| `.accordion` | Accordion/details element |
265+
| `.steps` | Step-by-step guide |
266+
| `.cards` | Card grid container |
267+
| `.card` | Individual card |
268+
| `.tiles` | Tile grid container |
269+
| `.panel` | Panel component |
270+
| `.badge` | Badge component |
271+
| `.tree` | File tree component |
272+
273+
**Navigation**
274+
275+
| Class | Element |
276+
|-------|---------|
277+
| `.page-nav` | Previous/next page links |
278+
| `.page-nav-link` | Individual prev/next link |
279+
| `.edit-link` | "Edit this page" link |
280+
| `.search-button` | Search trigger button |
281+
| `.theme-toggle` | Dark mode toggle button |
282+
283+
<Tip>
284+
Your custom CSS is injected after all built-in styles, so your rules will naturally override defaults. Use browser DevTools (right-click → Inspect) to explore the full class hierarchy and test styles live.
285+
</Tip>
286+
181287
## Code Themes
182288

183289
Configure syntax highlighting themes:
@@ -245,6 +351,7 @@ theme:
245351
radius: "8px"
246352
247353
custom:
354+
cssFile: styles/custom.css
248355
css: |
249356
/* Add a gradient to the header */
250357
.header {

lib/src/config/theme_config.dart

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,12 @@ class FontsConfig {
117117

118118
class CustomThemeConfig {
119119
final String? css;
120+
final String? cssFile;
120121

121-
const CustomThemeConfig({this.css});
122+
const CustomThemeConfig({this.css, this.cssFile});
122123

123-
factory CustomThemeConfig.fromYaml(Map yaml) => CustomThemeConfig(css: yaml['css'] as String?);
124+
factory CustomThemeConfig.fromYaml(Map yaml) => CustomThemeConfig(
125+
css: yaml['css'] as String?,
126+
cssFile: yaml['cssFile'] as String?,
127+
);
124128
}

lib/src/generator/builders/page_styles_builder.dart

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import '../../config/config.dart';
44
class PageStylesBuilder {
55
final StardustConfig config;
66

7+
/// Pre-resolved CSS file content, set externally before building
8+
String? resolvedCssFileContent;
9+
710
PageStylesBuilder({required this.config});
811

912
String buildFonts() {
@@ -62,10 +65,32 @@ ${_buildFooterStyles()}
6265
${_buildSocialStyles()}
6366
${_buildSyntaxHighlighting()}
6467
${_buildResponsiveStyles()}
68+
${_buildCustomStyles()}
6569
</style>
6670
''';
6771
}
6872

73+
String _buildCustomStyles() {
74+
final custom = config.theme.custom;
75+
if (custom == null) return '';
76+
77+
final buffer = StringBuffer();
78+
79+
// Include pre-resolved CSS file content
80+
if (resolvedCssFileContent case final String content when content.isNotEmpty) {
81+
buffer.write(content);
82+
}
83+
84+
// Append inline CSS
85+
if (custom.css case final String css when css.isNotEmpty) {
86+
if (buffer.isNotEmpty) buffer.writeln();
87+
buffer.write(css);
88+
}
89+
90+
if (buffer.isEmpty) return '';
91+
return '\n /* Custom styles */\n $buffer';
92+
}
93+
6994
String _buildBaseStyles() => '''
7095
* {
7196
margin: 0;

lib/src/generator/page_builder.dart

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ class PageBuilder {
1111
final StardustConfig config;
1212

1313
late final PageMetaBuilder _metaBuilder;
14-
late final PageStylesBuilder _stylesBuilder;
14+
late final PageStylesBuilder stylesBuilder;
1515
late final PageLayoutBuilder _layoutBuilder;
1616
late final PageScriptsBuilder _scriptsBuilder;
1717
late final PageAnalyticsBuilder _analyticsBuilder;
1818

1919
PageBuilder({required this.config}) {
2020
_metaBuilder = PageMetaBuilder(config: config);
21-
_stylesBuilder = PageStylesBuilder(config: config);
21+
stylesBuilder = PageStylesBuilder(config: config);
2222
_layoutBuilder = PageLayoutBuilder(config: config);
2323
_scriptsBuilder = PageScriptsBuilder(config: config);
2424
_analyticsBuilder = PageAnalyticsBuilder(analytics: config.integrations.analytics);
@@ -45,8 +45,8 @@ class PageBuilder {
4545
${_metaBuilder.buildFavicon()}
4646
${_metaBuilder.build(page)}
4747
${_analyticsBuilder.build()}
48-
${_stylesBuilder.buildFonts()}
49-
${_stylesBuilder.buildStyles()}
48+
${stylesBuilder.buildFonts()}
49+
${stylesBuilder.buildStyles()}
5050
${_scriptsBuilder.buildPagefindStyles(basePath)}
5151
</head>
5252
<body>

lib/src/generator/site_generator.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ class SiteGenerator {
3636

3737
/// Generate the static site, returns number of pages generated
3838
Future<int> generate() async {
39+
// Resolve custom CSS file content before building pages
40+
final cssFile = config.theme.custom?.cssFile;
41+
if (cssFile case final cssFile? when cssFile.isNotEmpty && await fileSystem.fileExists(cssFile)) {
42+
pageBuilder.stylesBuilder.resolvedCssFileContent = await fileSystem.readFile(cssFile);
43+
}
44+
3945
final contentDir = p.join(Directory.current.path, config.content.dir);
4046
final files = await _findMarkdownFiles(contentDir);
4147

schema/stardust.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,11 @@
219219
"properties": {
220220
"css": {
221221
"type": "string",
222-
"description": "Path to custom CSS file"
222+
"description": "Inline custom CSS styles"
223+
},
224+
"cssFile": {
225+
"type": "string",
226+
"description": "Path to a custom CSS file"
223227
}
224228
}
225229
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import 'package:stardust/src/config/config.dart';
2+
import 'package:stardust/src/generator/builders/page_styles_builder.dart';
3+
import 'package:test/test.dart';
4+
5+
void main() {
6+
group('PageStylesBuilder', () {
7+
group('custom CSS', () {
8+
test('includes custom CSS when configured', () {
9+
const config = StardustConfig(
10+
name: 'Test',
11+
theme: ThemeConfig(
12+
custom: CustomThemeConfig(
13+
css: '.my-class { color: red; }',
14+
),
15+
),
16+
);
17+
final builder = PageStylesBuilder(config: config);
18+
19+
final result = builder.buildStyles();
20+
21+
expect(result, contains('.my-class { color: red; }'));
22+
expect(result, contains('/* Custom styles */'));
23+
});
24+
25+
test('does not include custom styles section when not configured', () {
26+
const config = StardustConfig(name: 'Test');
27+
final builder = PageStylesBuilder(config: config);
28+
29+
final result = builder.buildStyles();
30+
31+
expect(result, isNot(contains('/* Custom styles */')));
32+
});
33+
34+
test('does not include custom styles section when css is empty', () {
35+
const config = StardustConfig(
36+
name: 'Test',
37+
theme: ThemeConfig(
38+
custom: CustomThemeConfig(css: ''),
39+
),
40+
);
41+
final builder = PageStylesBuilder(config: config);
42+
43+
final result = builder.buildStyles();
44+
45+
expect(result, isNot(contains('/* Custom styles */')));
46+
});
47+
48+
test('custom CSS appears after responsive styles', () {
49+
const config = StardustConfig(
50+
name: 'Test',
51+
theme: ThemeConfig(
52+
custom: CustomThemeConfig(
53+
css: '.custom { display: flex; }',
54+
),
55+
),
56+
);
57+
final builder = PageStylesBuilder(config: config);
58+
59+
final result = builder.buildStyles();
60+
61+
final responsiveIndex = result.indexOf('@media (max-width: 768px)');
62+
final customIndex = result.indexOf('.custom { display: flex; }');
63+
expect(customIndex, greaterThan(responsiveIndex));
64+
});
65+
66+
test('includes CSS from resolved file content', () {
67+
const config = StardustConfig(
68+
name: 'Test',
69+
theme: ThemeConfig(
70+
custom: CustomThemeConfig(cssFile: 'custom.css'),
71+
),
72+
);
73+
final builder = PageStylesBuilder(config: config);
74+
builder.resolvedCssFileContent = '.from-file { color: blue; }';
75+
76+
final result = builder.buildStyles();
77+
78+
expect(result, contains('.from-file { color: blue; }'));
79+
expect(result, contains('/* Custom styles */'));
80+
});
81+
82+
test('combines resolved file content and inline css', () {
83+
const config = StardustConfig(
84+
name: 'Test',
85+
theme: ThemeConfig(
86+
custom: CustomThemeConfig(
87+
cssFile: 'custom.css',
88+
css: '.inline { color: red; }',
89+
),
90+
),
91+
);
92+
final builder = PageStylesBuilder(config: config);
93+
builder.resolvedCssFileContent = '.from-file { color: blue; }';
94+
95+
final result = builder.buildStyles();
96+
97+
expect(result, contains('.from-file { color: blue; }'));
98+
expect(result, contains('.inline { color: red; }'));
99+
});
100+
101+
test('no custom styles when cssFile set but content not resolved', () {
102+
const config = StardustConfig(
103+
name: 'Test',
104+
theme: ThemeConfig(
105+
custom: CustomThemeConfig(cssFile: '/nonexistent/style.css'),
106+
),
107+
);
108+
final builder = PageStylesBuilder(config: config);
109+
110+
final result = builder.buildStyles();
111+
112+
expect(result, isNot(contains('/* Custom styles */')));
113+
});
114+
115+
test('supports CSS variable overrides', () {
116+
const config = StardustConfig(
117+
name: 'Test',
118+
theme: ThemeConfig(
119+
custom: CustomThemeConfig(
120+
css: ':root { --color-primary: #ff0000; }',
121+
),
122+
),
123+
);
124+
final builder = PageStylesBuilder(config: config);
125+
126+
final result = builder.buildStyles();
127+
128+
expect(result, contains('--color-primary: #ff0000'));
129+
});
130+
});
131+
});
132+
}

0 commit comments

Comments
 (0)