diff --git a/docs/config/theme.md b/docs/config/theme.md
index 4feae50..ef0ac61 100644
--- a/docs/config/theme.md
+++ b/docs/config/theme.md
@@ -142,7 +142,9 @@ theme:
## Custom CSS
-Add custom styles to your site:
+Add custom styles to your site inline or via a CSS file.
+
+### Inline CSS
```yaml
theme:
@@ -157,6 +159,26 @@ theme:
}
```
+### CSS File
+
+Point to an external CSS file for larger customizations:
+
+```yaml
+theme:
+ custom:
+ cssFile: styles/custom.css
+```
+
+You can use both together — the file is loaded first, then inline CSS is appended:
+
+```yaml
+theme:
+ custom:
+ cssFile: styles/base.css
+ css: |
+ .header { border-bottom: 2px solid var(--color-primary); }
+```
+
### CSS Variables
Stardust exposes CSS variables you can override:
@@ -165,12 +187,14 @@ Stardust exposes CSS variables you can override:
:root {
/* Colors */
--color-primary: #6366f1;
- --color-background: #ffffff;
+ --color-bg: #ffffff;
+ --color-bg-secondary: #f8fafc;
--color-text: #1e293b;
- --border-color: #e2e8f0;
+ --color-text-secondary: #64748b;
+ --color-border: #e2e8f0;
/* Fonts */
- --font-sans: 'Inter', sans-serif;
+ --font-sans: 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
/* Spacing */
@@ -178,6 +202,88 @@ Stardust exposes CSS variables you can override:
}
```
+In dark mode (`.dark`), the color variables are automatically updated. You can target dark mode specifically:
+
+```css
+.dark {
+ --color-bg: #0f172a;
+ --color-bg-secondary: #1e293b;
+ --color-text: #e2e8f0;
+ --color-text-secondary: #94a3b8;
+ --color-border: #334155;
+}
+```
+
+### CSS Classes Reference
+
+Use your browser's DevTools to inspect elements and discover classes. Here are the main targetable classes:
+
+**Layout**
+
+| Class | Element |
+|-------|---------|
+| `.header` | Top navigation bar |
+| `.header-inner` | Header content container |
+| `.logo` | Logo link |
+| `.logo-text` | Site name text |
+| `.nav` | Desktop navigation links |
+| `.header-actions` | Search, theme toggle, social links area |
+| `.sidebar` | Left sidebar navigation |
+| `.content` | Main content area |
+| `.toc` | Table of contents (right side) |
+| `.footer` | Page footer |
+
+**Sidebar**
+
+| Class | Element |
+|-------|---------|
+| `.sidebar-group` | A sidebar section |
+| `.sidebar-group-title` | Group heading (clickable when collapsible) |
+| `.sidebar-group-label` | Group name text |
+| `.sidebar-group-icon` | Icon next to group name |
+| `.sidebar-links` | List of links in a group |
+| `.sidebar-link` | Individual sidebar link |
+| `.sidebar-link.active` | Currently active page link |
+
+**Content**
+
+| Class | Element |
+|-------|---------|
+| `.prose` | Markdown content wrapper |
+| `.code-block` | Fenced code block container |
+| `.code-header` | Code block header (language label + copy button) |
+| `.copy-button` | Code copy button |
+
+**Components**
+
+| Class | Element |
+|-------|---------|
+| `.callout` | Callout/admonition blocks (Tip, Warning, etc.) |
+| `.tabs` | Tab container |
+| `.code-group` | Code group (tabbed code blocks) |
+| `.accordion` | Accordion/details element |
+| `.steps` | Step-by-step guide |
+| `.cards` | Card grid container |
+| `.card` | Individual card |
+| `.tiles` | Tile grid container |
+| `.panel` | Panel component |
+| `.badge` | Badge component |
+| `.tree` | File tree component |
+
+**Navigation**
+
+| Class | Element |
+|-------|---------|
+| `.page-nav` | Previous/next page links |
+| `.page-nav-link` | Individual prev/next link |
+| `.edit-link` | "Edit this page" link |
+| `.search-button` | Search trigger button |
+| `.theme-toggle` | Dark mode toggle button |
+
+
+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.
+
+
## Code Themes
Configure syntax highlighting themes:
@@ -245,6 +351,7 @@ theme:
radius: "8px"
custom:
+ cssFile: styles/custom.css
css: |
/* Add a gradient to the header */
.header {
diff --git a/lib/src/config/theme_config.dart b/lib/src/config/theme_config.dart
index bc5eead..2816eca 100644
--- a/lib/src/config/theme_config.dart
+++ b/lib/src/config/theme_config.dart
@@ -117,8 +117,12 @@ class FontsConfig {
class CustomThemeConfig {
final String? css;
+ final String? cssFile;
- const CustomThemeConfig({this.css});
+ const CustomThemeConfig({this.css, this.cssFile});
- factory CustomThemeConfig.fromYaml(Map yaml) => CustomThemeConfig(css: yaml['css'] as String?);
+ factory CustomThemeConfig.fromYaml(Map yaml) => CustomThemeConfig(
+ css: yaml['css'] as String?,
+ cssFile: yaml['cssFile'] as String?,
+ );
}
diff --git a/lib/src/generator/builders/page_styles_builder.dart b/lib/src/generator/builders/page_styles_builder.dart
index fc67b31..fde7f9e 100644
--- a/lib/src/generator/builders/page_styles_builder.dart
+++ b/lib/src/generator/builders/page_styles_builder.dart
@@ -4,6 +4,9 @@ import '../../config/config.dart';
class PageStylesBuilder {
final StardustConfig config;
+ /// Pre-resolved CSS file content, set externally before building
+ String? resolvedCssFileContent;
+
PageStylesBuilder({required this.config});
String buildFonts() {
@@ -62,10 +65,32 @@ ${_buildFooterStyles()}
${_buildSocialStyles()}
${_buildSyntaxHighlighting()}
${_buildResponsiveStyles()}
+${_buildCustomStyles()}
''';
}
+ String _buildCustomStyles() {
+ final custom = config.theme.custom;
+ if (custom == null) return '';
+
+ final buffer = StringBuffer();
+
+ // Include pre-resolved CSS file content
+ if (resolvedCssFileContent case final String content when content.isNotEmpty) {
+ buffer.write(content);
+ }
+
+ // Append inline CSS
+ if (custom.css case final String css when css.isNotEmpty) {
+ if (buffer.isNotEmpty) buffer.writeln();
+ buffer.write(css);
+ }
+
+ if (buffer.isEmpty) return '';
+ return '\n /* Custom styles */\n $buffer';
+ }
+
String _buildBaseStyles() => '''
* {
margin: 0;
diff --git a/lib/src/generator/page_builder.dart b/lib/src/generator/page_builder.dart
index 36c2085..38f6cf9 100644
--- a/lib/src/generator/page_builder.dart
+++ b/lib/src/generator/page_builder.dart
@@ -11,14 +11,14 @@ class PageBuilder {
final StardustConfig config;
late final PageMetaBuilder _metaBuilder;
- late final PageStylesBuilder _stylesBuilder;
+ late final PageStylesBuilder stylesBuilder;
late final PageLayoutBuilder _layoutBuilder;
late final PageScriptsBuilder _scriptsBuilder;
late final PageAnalyticsBuilder _analyticsBuilder;
PageBuilder({required this.config}) {
_metaBuilder = PageMetaBuilder(config: config);
- _stylesBuilder = PageStylesBuilder(config: config);
+ stylesBuilder = PageStylesBuilder(config: config);
_layoutBuilder = PageLayoutBuilder(config: config);
_scriptsBuilder = PageScriptsBuilder(config: config);
_analyticsBuilder = PageAnalyticsBuilder(analytics: config.integrations.analytics);
@@ -45,8 +45,8 @@ class PageBuilder {
${_metaBuilder.buildFavicon()}
${_metaBuilder.build(page)}
${_analyticsBuilder.build()}
- ${_stylesBuilder.buildFonts()}
- ${_stylesBuilder.buildStyles()}
+ ${stylesBuilder.buildFonts()}
+ ${stylesBuilder.buildStyles()}
${_scriptsBuilder.buildPagefindStyles(basePath)}
diff --git a/lib/src/generator/site_generator.dart b/lib/src/generator/site_generator.dart
index 8d2acb7..d863676 100644
--- a/lib/src/generator/site_generator.dart
+++ b/lib/src/generator/site_generator.dart
@@ -36,6 +36,12 @@ class SiteGenerator {
/// Generate the static site, returns number of pages generated
Future generate() async {
+ // Resolve custom CSS file content before building pages
+ final cssFile = config.theme.custom?.cssFile;
+ if (cssFile case final cssFile? when cssFile.isNotEmpty && await fileSystem.fileExists(cssFile)) {
+ pageBuilder.stylesBuilder.resolvedCssFileContent = await fileSystem.readFile(cssFile);
+ }
+
final contentDir = p.join(Directory.current.path, config.content.dir);
final files = await _findMarkdownFiles(contentDir);
diff --git a/schema/stardust.json b/schema/stardust.json
index 437842e..08de83b 100644
--- a/schema/stardust.json
+++ b/schema/stardust.json
@@ -219,7 +219,11 @@
"properties": {
"css": {
"type": "string",
- "description": "Path to custom CSS file"
+ "description": "Inline custom CSS styles"
+ },
+ "cssFile": {
+ "type": "string",
+ "description": "Path to a custom CSS file"
}
}
}
diff --git a/test/generator/builders/page_styles_builder_test.dart b/test/generator/builders/page_styles_builder_test.dart
new file mode 100644
index 0000000..55ea635
--- /dev/null
+++ b/test/generator/builders/page_styles_builder_test.dart
@@ -0,0 +1,132 @@
+import 'package:stardust/src/config/config.dart';
+import 'package:stardust/src/generator/builders/page_styles_builder.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('PageStylesBuilder', () {
+ group('custom CSS', () {
+ test('includes custom CSS when configured', () {
+ const config = StardustConfig(
+ name: 'Test',
+ theme: ThemeConfig(
+ custom: CustomThemeConfig(
+ css: '.my-class { color: red; }',
+ ),
+ ),
+ );
+ final builder = PageStylesBuilder(config: config);
+
+ final result = builder.buildStyles();
+
+ expect(result, contains('.my-class { color: red; }'));
+ expect(result, contains('/* Custom styles */'));
+ });
+
+ test('does not include custom styles section when not configured', () {
+ const config = StardustConfig(name: 'Test');
+ final builder = PageStylesBuilder(config: config);
+
+ final result = builder.buildStyles();
+
+ expect(result, isNot(contains('/* Custom styles */')));
+ });
+
+ test('does not include custom styles section when css is empty', () {
+ const config = StardustConfig(
+ name: 'Test',
+ theme: ThemeConfig(
+ custom: CustomThemeConfig(css: ''),
+ ),
+ );
+ final builder = PageStylesBuilder(config: config);
+
+ final result = builder.buildStyles();
+
+ expect(result, isNot(contains('/* Custom styles */')));
+ });
+
+ test('custom CSS appears after responsive styles', () {
+ const config = StardustConfig(
+ name: 'Test',
+ theme: ThemeConfig(
+ custom: CustomThemeConfig(
+ css: '.custom { display: flex; }',
+ ),
+ ),
+ );
+ final builder = PageStylesBuilder(config: config);
+
+ final result = builder.buildStyles();
+
+ final responsiveIndex = result.indexOf('@media (max-width: 768px)');
+ final customIndex = result.indexOf('.custom { display: flex; }');
+ expect(customIndex, greaterThan(responsiveIndex));
+ });
+
+ test('includes CSS from resolved file content', () {
+ const config = StardustConfig(
+ name: 'Test',
+ theme: ThemeConfig(
+ custom: CustomThemeConfig(cssFile: 'custom.css'),
+ ),
+ );
+ final builder = PageStylesBuilder(config: config);
+ builder.resolvedCssFileContent = '.from-file { color: blue; }';
+
+ final result = builder.buildStyles();
+
+ expect(result, contains('.from-file { color: blue; }'));
+ expect(result, contains('/* Custom styles */'));
+ });
+
+ test('combines resolved file content and inline css', () {
+ const config = StardustConfig(
+ name: 'Test',
+ theme: ThemeConfig(
+ custom: CustomThemeConfig(
+ cssFile: 'custom.css',
+ css: '.inline { color: red; }',
+ ),
+ ),
+ );
+ final builder = PageStylesBuilder(config: config);
+ builder.resolvedCssFileContent = '.from-file { color: blue; }';
+
+ final result = builder.buildStyles();
+
+ expect(result, contains('.from-file { color: blue; }'));
+ expect(result, contains('.inline { color: red; }'));
+ });
+
+ test('no custom styles when cssFile set but content not resolved', () {
+ const config = StardustConfig(
+ name: 'Test',
+ theme: ThemeConfig(
+ custom: CustomThemeConfig(cssFile: '/nonexistent/style.css'),
+ ),
+ );
+ final builder = PageStylesBuilder(config: config);
+
+ final result = builder.buildStyles();
+
+ expect(result, isNot(contains('/* Custom styles */')));
+ });
+
+ test('supports CSS variable overrides', () {
+ const config = StardustConfig(
+ name: 'Test',
+ theme: ThemeConfig(
+ custom: CustomThemeConfig(
+ css: ':root { --color-primary: #ff0000; }',
+ ),
+ ),
+ );
+ final builder = PageStylesBuilder(config: config);
+
+ final result = builder.buildStyles();
+
+ expect(result, contains('--color-primary: #ff0000'));
+ });
+ });
+ });
+}
diff --git a/test/generator/site_generator_test.dart b/test/generator/site_generator_test.dart
index 8418575..15e58e2 100644
--- a/test/generator/site_generator_test.dart
+++ b/test/generator/site_generator_test.dart
@@ -619,5 +619,66 @@ redirect_from:
expect(File(p.join(outputDir, '_redirects')).existsSync(), isFalse);
});
});
+
+ group('custom CSS file', () {
+ test('resolves cssFile content before building pages', () async {
+ await File(p.join(contentDir, 'index.md')).writeAsString('''
+---
+title: Home
+---
+
+# Home
+''');
+
+ final cssFilePath = p.join(tempDir.path, 'custom.css');
+ await File(cssFilePath).writeAsString('.from-file { color: blue; }');
+
+ final config = StardustConfig(
+ name: 'Test',
+ content: ContentConfig(dir: contentDir),
+ theme: ThemeConfig(
+ custom: CustomThemeConfig(cssFile: cssFilePath),
+ ),
+ );
+
+ final generator = SiteGenerator(
+ config: config,
+ outputDir: outputDir,
+ logger: Logger(onLog: (_) {}),
+ );
+
+ await generator.generate();
+
+ final html = await File(p.join(outputDir, 'index.html')).readAsString();
+ expect(html, contains('.from-file { color: blue; }'));
+ });
+
+ test('skips missing cssFile without error', () async {
+ await File(p.join(contentDir, 'index.md')).writeAsString('''
+---
+title: Home
+---
+
+# Home
+''');
+
+ final config = StardustConfig(
+ name: 'Test',
+ content: ContentConfig(dir: contentDir),
+ theme: const ThemeConfig(
+ custom: CustomThemeConfig(cssFile: '/nonexistent/style.css'),
+ ),
+ );
+
+ final generator = SiteGenerator(
+ config: config,
+ outputDir: outputDir,
+ logger: Logger(onLog: (_) {}),
+ );
+
+ final count = await generator.generate();
+ expect(count, equals(1));
+ });
+ });
});
}