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)); + }); + }); }); }