From 692cc4a65b4255336a40f143deea34295676e555 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Sat, 31 Jan 2026 10:51:14 +0100 Subject: [PATCH 1/2] feat: support custom CSS file in theme configuration Add cssFile option alongside inline css for custom theme styles. CSS file is resolved via FileSystem abstraction in SiteGenerator before page building. Both cssFile and inline css can be combined. --- docs/config/theme.md | 115 ++++++++++++++- lib/src/config/theme_config.dart | 8 +- .../builders/page_styles_builder.dart | 25 ++++ lib/src/generator/page_builder.dart | 8 +- lib/src/generator/site_generator.dart | 6 + schema/stardust.json | 6 +- .../builders/page_styles_builder_test.dart | 132 ++++++++++++++++++ 7 files changed, 289 insertions(+), 11 deletions(-) create mode 100644 test/generator/builders/page_styles_builder_test.dart 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')); + }); + }); + }); +} From 2ccf90bfb50537f46b2a0f8575873a7702d34c44 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Sat, 31 Jan 2026 11:13:15 +0100 Subject: [PATCH 2/2] chore: add tests for custom css file --- test/generator/site_generator_test.dart | 61 +++++++++++++++++++++++++ 1 file changed, 61 insertions(+) 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)); + }); + }); }); }