Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 111 additions & 4 deletions docs/config/theme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -165,19 +187,103 @@ 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 */
--radius: 8px;
}
```

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 |

<Tip>
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.
</Tip>

## Code Themes

Configure syntax highlighting themes:
Expand Down Expand Up @@ -245,6 +351,7 @@ theme:
radius: "8px"

custom:
cssFile: styles/custom.css
css: |
/* Add a gradient to the header */
.header {
Expand Down
8 changes: 6 additions & 2 deletions lib/src/config/theme_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
);
}
25 changes: 25 additions & 0 deletions lib/src/generator/builders/page_styles_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -62,10 +65,32 @@ ${_buildFooterStyles()}
${_buildSocialStyles()}
${_buildSyntaxHighlighting()}
${_buildResponsiveStyles()}
${_buildCustomStyles()}
</style>
''';
}

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;
Expand Down
8 changes: 4 additions & 4 deletions lib/src/generator/page_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -45,8 +45,8 @@ class PageBuilder {
${_metaBuilder.buildFavicon()}
${_metaBuilder.build(page)}
${_analyticsBuilder.build()}
${_stylesBuilder.buildFonts()}
${_stylesBuilder.buildStyles()}
${stylesBuilder.buildFonts()}
${stylesBuilder.buildStyles()}
${_scriptsBuilder.buildPagefindStyles(basePath)}
</head>
<body>
Expand Down
6 changes: 6 additions & 0 deletions lib/src/generator/site_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ class SiteGenerator {

/// Generate the static site, returns number of pages generated
Future<int> 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);

Expand Down
6 changes: 5 additions & 1 deletion schema/stardust.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down
132 changes: 132 additions & 0 deletions test/generator/builders/page_styles_builder_test.dart
Original file line number Diff line number Diff line change
@@ -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'));
});
});
});
}
Loading