diff --git a/site/build.yaml b/site/build.yaml index 951936a37d..2e131647c7 100644 --- a/site/build.yaml +++ b/site/build.yaml @@ -8,6 +8,18 @@ builders: auto_apply: root_package build_to: source + contentAssetsBuilder: + import: 'package:dart_dev_site/builders.dart' + builder_factories: [ 'contentAssetsBuilder' ] + build_extensions: + 'src/content/{{}}.png': ['web/images/content/{{}}.png'] + 'src/content/{{}}.jpg': ['web/images/content/{{}}.jpg'] + 'src/content/{{}}.jpeg': ['web/images/content/{{}}.jpeg'] + 'src/content/{{}}.gif': ['web/images/content/{{}}.gif'] + 'src/content/{{}}.svg': ['web/images/content/{{}}.svg'] + auto_apply: root_package + build_to: source + targets: $default: builders: @@ -28,3 +40,10 @@ targets: dart_dev_site:stylesHashBuilder: dev_options: fixed_hash: true + dart_dev_site:contentAssetsBuilder: + options: {} + sources: + - lib/** + - web/** + - src/content/** + - pubspec.yaml diff --git a/site/filesystem_loader_temp.dart b/site/filesystem_loader_temp.dart new file mode 100644 index 0000000000..1de9a2e849 --- /dev/null +++ b/site/filesystem_loader_temp.dart @@ -0,0 +1,174 @@ +import 'dart:async'; + +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:jaspr/server.dart'; +import 'package:jaspr_router/jaspr_router.dart'; +import 'package:path/path.dart' as p; +import 'package:watcher/watcher.dart'; + +import '../page.dart'; +import '../utils.dart'; +import 'route_loader.dart'; + +/// A loader that loads routes from the filesystem. +/// +/// Routes are constructed based on the recursive folder structure under the root [directory]. +/// Index files (index.*) are treated as the page for the containing folder. +/// Files and folders starting with an underscore (_) are ignored. +class FilesystemLoader extends RouteLoaderBase { + FilesystemLoader( + this.directory, { + this.keepSuffixPattern, + super.debugPrint, + @visibleForTesting this.fileSystem = const LocalFileSystem(), + @visibleForTesting DirectoryWatcherFactory? watcherFactory, + }) : watcherFactory = watcherFactory ?? _defaultWatcherFactory; + + /// The directory to load pages from. + final String directory; + + /// A pattern to keep the file suffix for all matching pages. + final Pattern? keepSuffixPattern; + + @visibleForTesting + final FileSystem fileSystem; + @visibleForTesting + final DirectoryWatcherFactory watcherFactory; + + static DirectoryWatcher _defaultWatcherFactory(String path) => DirectoryWatcher(path); + + final Map> dependentSources = {}; + + StreamSubscription? _watcherSub; + + @override + Future> loadRoutes(ConfigResolver resolver, bool eager) async { + if (kDebugMode) { + _watcherSub ??= watcherFactory(directory).events.listen((event) { + // It looks like event.path is relative on most platforms, but an + // absolute path on Linux. Turn this into the expected relative path. + final path = p.normalize(p.relative(event.path)); + if (event.type == ChangeType.MODIFY) { + invalidateFile(path); + } else if (event.type == ChangeType.REMOVE) { + removeFile(path); + } else if (event.type == ChangeType.ADD) { + addFile(path); + } + }); + } + return super.loadRoutes(resolver, eager); + } + + @override + void onReassemble() { + _watcherSub?.cancel(); + _watcherSub = null; + } + + @override + Future readPartial(String path, Page page) { + return _getPartial(path, page).readAsString(); + } + + @override + String readPartialSync(String path, Page page) { + return _getPartial(path, page).readAsStringSync(); + } + + File _getPartial(String path, Page page) { + final pageSource = getSourceForPage(page); + if (pageSource != null) { + (dependentSources[path] ??= {}).add(pageSource); + } + return fileSystem.file(path); + } + + @override + Future> loadPageSources() async { + final root = fileSystem.directory(directory); + if (!await root.exists()) { + return []; + } + + List loadFiles(Directory dir) { + final List entities = []; + for (final entry in dir.listSync()) { + final path = entry.path.substring(root.path.length + 1); + if (entry is File) { + entities.add( + FilePageSource( + path, + entry, + this, + keepSuffix: keepSuffixPattern?.matchAsPrefix(entry.path) != null, + context: fileSystem.path, + ), + ); + } else if (entry is Directory) { + entities.addAll(loadFiles(entry)); + } + } + return entities; + } + + return loadFiles(root); + } + + void addFile(String path) { + addSource( + FilePageSource( + path.substring(directory.length + 1), + fileSystem.file(path), + this, + keepSuffix: keepSuffixPattern?.matchAsPrefix(path) != null, + context: fileSystem.path, + ), + ); + } + + void removeFile(String path) { + final source = sources.whereType().where((source) => source.file.path == path).firstOrNull; + if (source != null) { + removeSource(source); + } + } + + void invalidateFile(String path, {bool rebuild = true}) { + final source = sources.whereType().where((source) => source.file.path == path).firstOrNull; + if (source != null) { + invalidateSource(source, rebuild: rebuild); + } + } + + @override + void invalidateSource(PageSource source, {bool rebuild = true}) { + super.invalidateSource(source, rebuild: rebuild); + final fullPath = fileSystem.path.join(directory, source.path); + final dependencies = {...?dependentSources[fullPath]}; + dependentSources[fullPath]?.clear(); + for (final dependent in dependencies) { + invalidateSource(dependent, rebuild: rebuild); + } + } + + @override + void invalidateAll() { + super.invalidateAll(); + dependentSources.clear(); + } +} + +class FilePageSource extends PageSource { + FilePageSource(super.path, this.file, super.loader, {super.keepSuffix, super.context}); + + final File file; + + @override + Future buildPage() async { + final content = await file.readAsString(); + + return Page(path: path, url: url, content: content, config: config, loader: loader); + } +} diff --git a/site/lib/_sass/_site.scss b/site/lib/_sass/_site.scss index d43506a5d7..05add1a549 100644 --- a/site/lib/_sass/_site.scss +++ b/site/lib/_sass/_site.scss @@ -17,6 +17,7 @@ @use 'components/banner'; @use 'components/breadcrumbs'; @use 'components/buttons'; +@use 'components/blog'; @use 'components/card'; @use 'components/code'; @use 'components/content'; diff --git a/site/lib/_sass/components/_blog.scss b/site/lib/_sass/components/_blog.scss new file mode 100644 index 0000000000..b65564f3c3 --- /dev/null +++ b/site/lib/_sass/components/_blog.scss @@ -0,0 +1,253 @@ +.blog-index { + display: flex; + flex-direction: column; + gap: 2rem; + /* Reduced from 3rem */ + width: 100%; + max-width: 1400px; + /* Wider container */ + margin: 0 auto; + padding: 3rem 2rem; + box-sizing: border-box; +} + +.blog-categories { + display: flex; + justify-content: flex-start; + gap: 2rem; + margin-bottom: 2rem; + border-bottom: 1px solid var(--site-outline); + overflow-x: auto; + -webkit-overflow-scrolling: touch; + /* Hide scrollbar but keep functionality */ + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + @media (max-width: 600px) { + gap: 1.5rem; + padding-left: 1rem; + padding-right: 1rem; + /* Ensure content isn't cut off on edges */ + margin-left: -1rem; + margin-right: -1rem; + } +} + +.blog-category-chip { + padding: 0.75rem 0; + border: none; + border-bottom: 2px solid transparent; + background-color: transparent; + color: var(--site-base-fgColor-alt); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: color 0.2s ease, border-color 0.2s ease; + white-space: nowrap; + margin-bottom: -1px; + /* Overlap the container border */ + + &:hover { + color: var(--site-base-fgColor); + background-color: transparent; + } + + &.active { + background-color: transparent; + color: var(--site-base-fgColor); + border-bottom-color: var(--site-base-fgColor); + } +} + + +.blog-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + /* Force 3 columns on large screens */ + gap: 2rem; + + @media (max-width: 1024px) { + grid-template-columns: repeat(2, 1fr); + } + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } +} + +.blog-card { + display: flex; + flex-direction: column; + gap: 0.5rem; // Reduced from 1rem for tighter spacing + text-decoration: none; + color: inherit; + + /* Featured Card Styles */ + &.featured { + padding-bottom: 2rem; + /* Reduced from 3rem */ + border-bottom: 1px solid var(--site-outline); + margin-bottom: 0; + /* Reduced from 1rem */ + + @media (min-width: 900px) { + flex-direction: row; + align-items: flex-start; // Align items to top + gap: 3rem; + + .blog-card-image { + flex: 2; // ~66% width + + /* Larger image area (~64%) */ + img { + width: 100%; + height: auto; + aspect-ratio: 2.25/1; // Wider and shorter + object-fit: cover; + border-radius: 4px; + } + } + + .blog-card-content { + flex: 1; // ~33% width + /* Smaller text area (~36%) */ + display: flex; + flex-direction: column; + justify-content: flex-start; // Align to top + } + + .blog-card-title { + font-size: 2rem; // Reduced from 3.5rem + /* Larger title */ + line-height: 1.1; + /* Tighter line height for large text */ + letter-spacing: -0.02em; + margin-top: 0; // Remove default margin for strict top alignment + margin-bottom: 0.5rem; // Reduced from 1rem + font-weight: 800; + /* Extra bold */ + } + + .blog-card-description { + font-size: 1.125rem; + color: var(--site-base-fgColor-alt); + margin-bottom: 0.75rem; // Reduced from 1.5rem + line-height: 1.4; + } + } + } + + /* Standard Card Styles (in grid) */ + &:not(.featured) { + height: 100%; + overflow: hidden; + /* Ensure content doesn't spill out of rounded corners */ + border: 1px solid var(--site-outline); + border-radius: 12px; + padding: 1rem; + transition: transform 0.2s ease, box-shadow 0.2s ease; + background-color: var(--site-bg-color, transparent); + + &:hover { + transform: translateY(-4px); + box-shadow: 0 10px 20px -5px rgba(0, 0, 0, 0.1); + border-color: var(--site-primary-color); + } + + .blog-card-image { + margin-bottom: 1rem; + /* Re-add margin for spacing inside the card */ + + img { + width: 100%; + height: auto; + aspect-ratio: 16/10; + object-fit: cover; + border-radius: 8px; + /* Slightly tighter radius inside the card */ + } + } + + .blog-card-content { + flex: 1; + display: flex; + flex-direction: column; + } + + .blog-card-title { + font-size: 1.35rem; + line-height: 1.2; + margin-bottom: 0.5rem; + } + + .blog-card-description { + font-size: 1rem; + color: var(--site-base-fgColor-alt); + margin-bottom: 1rem; + /* Increased spacing */ + display: -webkit-box; + -webkit-line-clamp: 2; + /* Clamp to 2 lines */ + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + } +} + +.blog-card-title { + font-weight: 800; + color: var(--site-base-fgColor); + + a { + color: inherit; + text-decoration: none; + + &:hover { + text-decoration: none; + color: var(--site-primary-color); + } + } +} + +.blog-card-meta { + font-size: 0.875rem; + color: var(--site-base-fgColor-alt); + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: auto; + + .blog-card-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + object-fit: cover; + background-color: var(--site-outline); // Placeholder bg + } + + .blog-card-author-row { + display: flex; + align-items: center; + gap: 0.25rem; + } + + .author, + .author-link { + font-weight: 600; + color: var(--site-base-fgColor); + text-decoration: none; + } + + .author-link:hover { + text-decoration: underline; + } +} + +.blog-card-image img { + max-width: 100%; + display: block; +} diff --git a/site/lib/builders.dart b/site/lib/builders.dart index e94693e9c7..07d09ef198 100644 --- a/site/lib/builders.dart +++ b/site/lib/builders.dart @@ -1,9 +1,39 @@ -// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file -// for details. All rights reserved. Use of this source code is governed by a -// BSD-style license that can be found in the LICENSE file. +import 'dart:async'; import 'package:build/build.dart'; +import 'package:path/path.dart' as p; -import 'src/builders/styles_hash_builder.dart' show StylesHashBuilder; +import 'src/builders/styles_hash_builder.dart'; Builder stylesHashBuilder(BuilderOptions options) => StylesHashBuilder(options); +Builder contentAssetsBuilder(BuilderOptions options) => ContentAssetsBuilder(); + +class ContentAssetsBuilder implements Builder { + @override + final buildExtensions = const { + 'src/content/{{}}.png': ['web/images/content/{{}}.png'], + 'src/content/{{}}.jpg': ['web/images/content/{{}}.jpg'], + 'src/content/{{}}.jpeg': ['web/images/content/{{}}.jpeg'], + 'src/content/{{}}.gif': ['web/images/content/{{}}.gif'], + 'src/content/{{}}.svg': ['web/images/content/{{}}.svg'], + }; + + @override + Future build(BuildStep buildStep) async { + final inputId = buildStep.inputId; + // extension is not needed as we just read/write bytes + + // Calculate output path: src/content/foo/bar.png -> web/images/content/foo/bar.png + // The {{}} captured part is "foo/bar" + + // We can rely on allowedOutputs if we match the input + final allowedOutputs = buildStep.allowedOutputs; + if (allowedOutputs.isEmpty) return; + + final outputId = allowedOutputs.first; + await buildStep.writeAsBytes( + outputId, + await buildStep.readAsBytes(inputId), + ); + } +} diff --git a/site/lib/main.client.options.dart b/site/lib/main.client.options.dart index c4f9cd9132..df37ce825a 100644 --- a/site/lib/main.client.options.dart +++ b/site/lib/main.client.options.dart @@ -8,6 +8,10 @@ import 'package:jaspr/client.dart'; import 'package:dart_dev_site/src/archive/archive_table.dart' deferred as _archive_table; +import 'package:dart_dev_site/src/components/blog/blog_list.dart' + deferred as _blog_list; +import 'package:dart_dev_site/src/components/common/client/collapse_button.dart' + deferred as _collapse_button; import 'package:dart_dev_site/src/components/common/client/cookie_notice.dart' deferred as _cookie_notice; import 'package:dart_dev_site/src/components/common/client/copy_button.dart' @@ -51,14 +55,29 @@ ClientOptions get defaultClientOptions => ClientOptions( (p) => _archive_table.ArchiveTable(channel: p['channel'] as String), loader: _archive_table.loadLibrary, ), + 'blog_list': ClientLoader( + (p) => _blog_list.BlogList( + posts: (p['posts'] as List) + .map((i) => (i as Map)) + .toList(), + ), + loader: _blog_list.loadLibrary, + ), + 'collapse_button': ClientLoader( + (p) => _collapse_button.CollapseButton( + classes: (p['classes'] as List).cast(), + title: p['title'] as String?, + ), + loader: _collapse_button.loadLibrary, + ), 'cookie_notice': ClientLoader( (p) => _cookie_notice.CookieNotice(), loader: _cookie_notice.loadLibrary, ), 'copy_button': ClientLoader( (p) => _copy_button.CopyButton( - toCopy: p['toCopy'] as String, buttonText: p['buttonText'] as String?, + toCopy: p['toCopy'] as String?, classes: (p['classes'] as List).cast(), title: p['title'] as String?, ), diff --git a/site/lib/main.server.dart b/site/lib/main.server.dart index e119e2508e..e170c2f54c 100644 --- a/site/lib/main.server.dart +++ b/site/lib/main.server.dart @@ -10,6 +10,7 @@ import 'package:path/path.dart' as path; import 'main.server.options.dart'; // Generated. Do not remove or edit. import 'src/archive/archive_table.dart'; +import 'src/components/blog/blog_index.dart'; import 'src/components/common/card.dart'; import 'src/components/common/tabs.dart'; import 'src/components/common/youtube_embed.dart'; @@ -18,6 +19,8 @@ import 'src/layouts/doc_layout.dart'; import 'src/layouts/homepage_layout.dart'; import 'src/layouts/learn_layout.dart'; import 'src/loaders/data_processor.dart'; +import 'src/loaders/blog_loader.dart'; +import 'src/loaders/filtered_filesystem_loader.dart'; import 'src/markdown/markdown_parser.dart'; import 'src/pages/custom_pages.dart'; import 'src/pages/diagnostic_index.dart'; @@ -36,13 +39,17 @@ void main() { Component get _dartDevSite => ContentApp.custom( eagerlyLoadAllPages: true, loaders: [ - FilesystemLoader(path.join(siteSrcDirectoryPath, 'content')), + FilteredFilesystemLoader( + path.join(siteSrcDirectoryPath, 'content'), + extensions: const {'.md', '.html'}, + ), MemoryLoader(pages: allMemoryPages), ], configResolver: PageConfig.all( dataLoaders: [ FilesystemDataLoader(path.join(siteSrcDirectoryPath, 'data')), DataProcessor(), + BlogDataLoader(), ], templateEngine: DashTemplateEngine( partialDirectoryPath: path.canonicalize( @@ -103,4 +110,8 @@ List get _embeddableComponents => [ pattern: RegExp('Card', caseSensitive: false), builder: (_, attrs, child) => Card.fromAttributes(attrs, child), ), + CustomComponent( + pattern: RegExp('BlogIndex', caseSensitive: false), + builder: (_, _, _) => const BlogIndex(), + ), ]; diff --git a/site/lib/main.server.options.dart b/site/lib/main.server.options.dart index 5f0f775d21..580e210011 100644 --- a/site/lib/main.server.options.dart +++ b/site/lib/main.server.options.dart @@ -6,6 +6,9 @@ import 'package:jaspr/server.dart'; import 'package:dart_dev_site/src/archive/archive_table.dart' as _archive_table; +import 'package:dart_dev_site/src/components/blog/blog_list.dart' as _blog_list; +import 'package:dart_dev_site/src/components/common/client/collapse_button.dart' + as _collapse_button; import 'package:dart_dev_site/src/components/common/client/cookie_notice.dart' as _cookie_notice; import 'package:dart_dev_site/src/components/common/client/copy_button.dart' @@ -51,6 +54,15 @@ ServerOptions get defaultServerOptions => ServerOptions( 'archive_table', params: __archive_tableArchiveTable, ), + _blog_list.BlogList: ClientTarget<_blog_list.BlogList>( + 'blog_list', + params: __blog_listBlogList, + ), + _collapse_button.CollapseButton: + ClientTarget<_collapse_button.CollapseButton>( + 'collapse_button', + params: __collapse_buttonCollapseButton, + ), _cookie_notice.CookieNotice: ClientTarget<_cookie_notice.CookieNotice>( 'cookie_notice', ), @@ -95,9 +107,15 @@ ServerOptions get defaultServerOptions => ServerOptions( Map __archive_tableArchiveTable( _archive_table.ArchiveTable c, ) => {'channel': c.channel}; +Map __blog_listBlogList(_blog_list.BlogList c) => { + 'posts': c.posts, +}; +Map __collapse_buttonCollapseButton( + _collapse_button.CollapseButton c, +) => {'classes': c.classes, 'title': c.title}; Map __copy_buttonCopyButton(_copy_button.CopyButton c) => { - 'toCopy': c.toCopy, 'buttonText': c.buttonText, + 'toCopy': c.toCopy, 'classes': c.classes, 'title': c.title, }; diff --git a/site/lib/src/components/blog/blog_card.dart b/site/lib/src/components/blog/blog_card.dart new file mode 100644 index 0000000000..db34d487dd --- /dev/null +++ b/site/lib/src/components/blog/blog_card.dart @@ -0,0 +1,67 @@ +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; + +class BlogCard extends StatelessComponent { + const BlogCard({ + required this.title, + required this.date, + required this.description, + required this.href, + this.image, + this.author, + this.avatar, + this.authorUrl, + this.isFeatured = false, + super.key, + }); + + final String title; + final String date; + final String description; + final String href; + final String? image; + final String? author; + final String? avatar; + final String? authorUrl; + final bool isFeatured; + + @override + Component build(BuildContext context) { + return div( + classes: 'blog-card ${isFeatured ? 'featured' : ''}', + [ + if (image != null) + div(classes: 'blog-card-image', [ + img(src: image!, alt: title), + ]), + div(classes: 'blog-card-content', [ + a(href: href, [ + h3(classes: 'blog-card-title', [text(title)]), + ]), + p(classes: 'blog-card-description', [text(description)]), + div(classes: 'blog-card-meta', [ + if (avatar != null) + img( + classes: 'blog-card-avatar', + src: avatar!, + alt: author ?? 'Author', + ), + div(classes: 'blog-card-author-row', [ + if (authorUrl != null) + a( + href: authorUrl!, + target: Target.blank, + classes: 'author-link', + [text(author ?? 'Unknown')], + ) + else + span(classes: 'author', [text(author ?? 'Unknown')]), + span(classes: 'separator', [text('·')]), + span(classes: 'date', [text(date)]), + ]), + ]), + ]), + ], + ); + } +} diff --git a/site/lib/src/components/blog/blog_index.dart b/site/lib/src/components/blog/blog_index.dart new file mode 100644 index 0000000000..90ac82646b --- /dev/null +++ b/site/lib/src/components/blog/blog_index.dart @@ -0,0 +1,23 @@ +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; +import 'package:jaspr_content/jaspr_content.dart'; + +import 'blog_list.dart'; +export 'blog_list.dart'; + +class BlogIndex extends StatelessComponent { + const BlogIndex({super.key}); + + @override + Component build(BuildContext context) { + final page = context.page; + final allPosts = (page.data['blog_posts'] as List?) + ?.cast>(); + + if (allPosts == null || allPosts.isEmpty) { + return p([text('No blog posts found.')]); + } + + return BlogList(posts: allPosts); + } +} diff --git a/site/lib/src/components/blog/blog_list.dart b/site/lib/src/components/blog/blog_list.dart new file mode 100644 index 0000000000..656dfe2971 --- /dev/null +++ b/site/lib/src/components/blog/blog_list.dart @@ -0,0 +1,94 @@ +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; + +import 'blog_card.dart'; + +@client +class BlogList extends StatefulComponent { + const BlogList({required this.posts, super.key}); + + final List> posts; + + @override + State createState() => _BlogListState(); +} + +class _BlogListState extends State { + String selectedCategory = 'all'; + + static const categories = [ + 'all', + 'spotlight', + 'events', + 'releases', + 'tutorials', + ]; + + @override + Component build(BuildContext context) { + final filteredPosts = selectedCategory == 'all' + ? component.posts + : component.posts + .where((post) => post['category'] == selectedCategory) + .toList(); + + Map? featured; + Iterable> others = []; + + if (filteredPosts.isNotEmpty) { + featured = filteredPosts.first; + others = filteredPosts.skip(1); + } + + return div(classes: 'blog-index', [ + // Categories + div(classes: 'blog-categories', [ + for (final category in categories) + button( + classes: + 'blog-category-chip ${category == selectedCategory ? 'active' : ''}', + onClick: () { + setState(() { + selectedCategory = category; + }); + }, + [ + text(category[0].toUpperCase() + category.substring(1)), + ], + ), + ]), + + if (featured != null) + BlogCard( + title: (featured['title'] as String?) ?? 'Untitled', + date: (featured['date'] as String?) ?? '', + description: (featured['description'] as String?) ?? '', + href: (featured['href'] as String?) ?? '#', + image: featured['image'] as String?, + author: featured['author'] as String?, + avatar: featured['avatar'] as String?, + authorUrl: featured['author_url'] as String?, + isFeatured: true, + ), + + if (others.isNotEmpty) + div(classes: 'blog-grid', [ + for (final post in others) + BlogCard( + title: (post['title'] as String?) ?? 'Untitled', + date: (post['date'] as String?) ?? '', + description: (post['description'] as String?) ?? '', + href: (post['href'] as String?) ?? '#', + image: post['image'] as String?, + author: post['author'] as String?, + avatar: post['avatar'] as String?, + authorUrl: post['author_url'] as String?, + ), + ]) + else if (filteredPosts.isEmpty) + div(classes: 'no-posts', [ + text('No posts found in this category.'), + ]), + ]); + } +} diff --git a/site/lib/src/extensions/image_path_processor.dart b/site/lib/src/extensions/image_path_processor.dart new file mode 100644 index 0000000000..69f24d7227 --- /dev/null +++ b/site/lib/src/extensions/image_path_processor.dart @@ -0,0 +1,45 @@ +import 'package:jaspr_content/jaspr_content.dart'; +import 'package:path/path.dart' as p; + +class ImagePathProcessor implements PageExtension { + const ImagePathProcessor(); + + @override + Future> apply(Page page, List nodes) async { + return _processNodes(nodes, page); + } + + List _processNodes(List nodes, Page page) { + return nodes.map((node) { + if (node is ElementNode && node.tag.toLowerCase() == 'img') { + final src = node.attributes['src']; + if (src != null && !src.startsWith('http') && !src.startsWith('/')) { + // It's a relative path. Rewrite it. + // page.path is likely "blog/test-folder-post/test-folder-post.md" or similar. + // We want: /images/content/blog/test-folder-post/dash.png + + final contentDir = p.dirname(page.path); + // If page.path is "blog/test-folder-post", dirname is "blog", which might be wrong for index.md? + // But if page.path comes from FilesystemLoader, it usually includes extension or is the ID. + + // Let's assume contentAssetsBuilder mirrors the src/content structure to web/images/content + final newSrc = p.join('/images/content', contentDir, src); + + return ElementNode( + node.tag, + {...node.attributes, 'src': newSrc}, + node.children, + ); + } + } + if (node is ElementNode && node.children != null) { + return ElementNode( + node.tag, + node.attributes, + _processNodes(node.children!, page), + ); + } + return node; + }).toList(); + } +} diff --git a/site/lib/src/extensions/registry.dart b/site/lib/src/extensions/registry.dart index a4586e29e0..77e8f05130 100644 --- a/site/lib/src/extensions/registry.dart +++ b/site/lib/src/extensions/registry.dart @@ -10,6 +10,7 @@ import 'glossary_link_processor.dart'; import 'header_extractor.dart'; import 'header_processor.dart'; import 'table_processor.dart'; +import 'image_path_processor.dart'; /// A list of all node-processing, page extensions to applied to /// content loaded with Jaspr Content. @@ -20,4 +21,5 @@ const List allNodeProcessingExtensions = [ TableWrapperExtension(), CodeBlockProcessor(), GlossaryLinkProcessor(), + ImagePathProcessor(), ]; diff --git a/site/lib/src/layouts/doc_layout.dart b/site/lib/src/layouts/doc_layout.dart index 341a2f4dc2..68c41e963a 100644 --- a/site/lib/src/layouts/doc_layout.dart +++ b/site/lib/src/layouts/doc_layout.dart @@ -28,7 +28,7 @@ class DocLayout extends DashLayout { @override Component buildBody(Page page, Component child) { final pageData = page.data.page; - final pageTitle = pageData['title'] as String; + final pageTitle = pageData['title'] as String? ?? 'Untitled'; final tocData = _tocForPage(page); return super.buildBody( diff --git a/site/lib/src/loaders/blog_loader.dart b/site/lib/src/loaders/blog_loader.dart new file mode 100644 index 0000000000..14d0a80ae8 --- /dev/null +++ b/site/lib/src/loaders/blog_loader.dart @@ -0,0 +1,146 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import 'package:jaspr_content/jaspr_content.dart'; +import 'package:path/path.dart' as p; + +import '../util.dart'; + +class BlogDataLoader implements DataLoader { + @override + Future loadData(Page page) async { + // Only load blog data for the blog index page + print('BlogDataLoader: Checking page path: ${page.path}'); + // Allow blog/index, blog, and blog/index.md (just to be safe) + if (page.path != 'blog/index' && + page.path != 'blog' && + page.path != 'blog/index.md') { + return; + } + + final blogDir = Directory(p.join(siteSrcDirectoryPath, 'content', 'blog')); + if (!blogDir.existsSync()) { + return; + } + + final posts = >[]; + + // Simple memory cache for GitHub users to avoid hitting rate limits + // during a single build + final githubCache = >{}; + + for (final entity in blogDir.listSync(recursive: true)) { + if (entity is! File) continue; + if (p.extension(entity.path) != '.md') continue; + // Exclude the root index.md of the blog + if (p.basename(entity.path) == 'index.md' && + entity.parent.path == blogDir.path) { + continue; + } + + final file = entity; + final fileContent = file.readAsStringSync(); + final frontmatter = _parseFrontmatter(fileContent); + + String authorName = frontmatter['author'] ?? 'Dart Team'; + String? avatarUrl; + String? profileUrl; + final handle = frontmatter['github_handle']; + + if (handle != null) { + profileUrl = 'https://github.com/$handle'; + avatarUrl = 'https://github.com/$handle.png'; + + // Try to fetch real name if not in cache + if (githubCache.containsKey(handle)) { + authorName = githubCache[handle]!['name']!; + } else { + try { + // Note: In a real CI/CD environment, you'd want a GITHUB_TOKEN here. + // For local dev/static sites, unauthenticated requests usually suffice + // for small volume. + final url = Uri.parse('https://api.github.com/users/$handle'); + final response = await http.get( + url, + headers: { + // User-Agent is required by GitHub API + 'User-Agent': 'Dart-Blog-Loader', + 'Accept': 'application/vnd.github.v3+json', + }, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + if (data['name'] != null) { + authorName = data['name'] as String; + } + } + // Cache the result (even if failed, to avoid retrying) + githubCache[handle] = {'name': authorName}; + } catch (e) { + print('Failed to fetch GitHub data for $handle: $e'); + // Fallback is already set to default or whatever we have + } + } + } + + String? image = frontmatter['image']; + if (image != null && + !image.startsWith('http') && + !image.startsWith('/')) { + final pathFromContent = p.relative( + file.parent.path, + from: p.join(siteSrcDirectoryPath, 'content'), + ); + image = p.join('/images/content', pathFromContent, image); + } + + posts.add({ + 'title': frontmatter['title'] ?? 'Untitled', + 'date': frontmatter['date'] ?? '', + 'description': frontmatter['description'] ?? '', + 'href': + '/blog/${p.basenameWithoutExtension(file.path) == 'index' ? p.basename(file.parent.path) : p.basenameWithoutExtension(file.path)}', + 'image': image, + 'author': authorName, + 'avatar': avatarUrl, + 'author_url': profileUrl, + 'category': frontmatter['category']?.toLowerCase() ?? 'all', + }); + } + + // Sort by date descending + posts.sort((a, b) { + final dateA = + DateTime.tryParse(a['date'] as String? ?? '') ?? DateTime(1970); + final dateB = + DateTime.tryParse(b['date'] as String? ?? '') ?? DateTime(1970); + return dateB.compareTo(dateA); + }); + + page.apply(data: {'blog_posts': posts}); + } + + Map _parseFrontmatter(String content) { + final match = RegExp(r'^---\n(.*?)\n---', dotAll: true).firstMatch(content); + if (match == null) return {}; + + final yamlContent = match.group(1)!; + final map = {}; + + for (final line in yamlContent.split('\n')) { + final parts = line.split(':'); + if (parts.length >= 2) { + final key = parts[0].trim(); + var value = parts.sublist(1).join(':').trim(); + if (value.startsWith('"') && value.endsWith('"')) { + value = value.substring(1, value.length - 1); + } + map[key] = value; + } + } + return map; + } +} diff --git a/site/lib/src/loaders/filtered_filesystem_loader.dart b/site/lib/src/loaders/filtered_filesystem_loader.dart new file mode 100644 index 0000000000..6a6f012315 --- /dev/null +++ b/site/lib/src/loaders/filtered_filesystem_loader.dart @@ -0,0 +1,24 @@ +import 'package:jaspr_content/jaspr_content.dart'; +import 'package:path/path.dart' as p; + +/// A [FilesystemLoader] that filters files by extension. +class FilteredFilesystemLoader extends FilesystemLoader { + final Set extensions; + + FilteredFilesystemLoader(super.directory, {required this.extensions}); + + @override + Future> loadPageSources() async { + final sources = await super.loadPageSources(); + return sources.where((source) { + return extensions.contains(p.extension(source.path)); + }).toList(); + } + + @override + void addFile(String path) { + if (extensions.contains(p.extension(path))) { + super.addFile(path); + } + } +} diff --git a/site/lib/src/markdown/markdown_parser.dart b/site/lib/src/markdown/markdown_parser.dart index 430c2eeba3..60bed3b524 100644 --- a/site/lib/src/markdown/markdown_parser.dart +++ b/site/lib/src/markdown/markdown_parser.dart @@ -86,7 +86,9 @@ String parseMarkdownToHtml(String markdownString, {bool inline = false}) { return renderer.render(nodes); } -final RegExp _markdownFilePattern = RegExp(r'.*\.md$'); +final RegExp _markdownFilePattern = RegExp( + r'(.*\.md$)|(^blog$)|(^blog/index$)', +); class DashMarkdownParser implements PageParser { const DashMarkdownParser(); diff --git a/site/src b/site/src new file mode 120000 index 0000000000..5cd551cf26 --- /dev/null +++ b/site/src @@ -0,0 +1 @@ +../src \ No newline at end of file diff --git a/site/web/images/content/blog/hello-world/hello-world-banner.png b/site/web/images/content/blog/hello-world/hello-world-banner.png new file mode 100644 index 0000000000..99199948bf Binary files /dev/null and b/site/web/images/content/blog/hello-world/hello-world-banner.png differ diff --git a/site/web/images/content/blog/jaimes-build-context/jaimes-build-context-banner.jpeg b/site/web/images/content/blog/jaimes-build-context/jaimes-build-context-banner.jpeg new file mode 100644 index 0000000000..c308a04076 Binary files /dev/null and b/site/web/images/content/blog/jaimes-build-context/jaimes-build-context-banner.jpeg differ diff --git a/site/web/images/content/blog/test-folder-post/dash.png b/site/web/images/content/blog/test-folder-post/dash.png new file mode 100644 index 0000000000..df8e789b0a Binary files /dev/null and b/site/web/images/content/blog/test-folder-post/dash.png differ diff --git a/site/web/main.client.dart b/site/web/main.client.dart new file mode 100644 index 0000000000..3e3e5e8c2e --- /dev/null +++ b/site/web/main.client.dart @@ -0,0 +1,12 @@ +// dart format off +// ignore_for_file: type=lint + +// GENERATED FILE, DO NOT MODIFY +// Generated with jaspr_builder + +import 'package:jaspr/client.dart'; +import 'package:dart_dev_site/main.client.dart' as _main$client; + +void main() { + _main$client.main(); +} diff --git a/src/content/blog/hello-world/hello-world-banner.png b/src/content/blog/hello-world/hello-world-banner.png new file mode 100644 index 0000000000..99199948bf Binary files /dev/null and b/src/content/blog/hello-world/hello-world-banner.png differ diff --git a/src/content/blog/hello-world/index.md b/src/content/blog/hello-world/index.md new file mode 100644 index 0000000000..11eac1e78f --- /dev/null +++ b/src/content/blog/hello-world/index.md @@ -0,0 +1,16 @@ +--- +title: "Hello World" +date: 2025-12-13 +description: "Welcome to the Dart blog!" +github_handle: "dash" +layout: docs +sidenav: "" +image: hello-world-banner.png +category: releases +--- + +![Hello World](hello-world-banner.png) + +Welcome to the **Dart** blog! + +This is a sample post to verify the blog implementation. diff --git a/src/content/blog/index.md b/src/content/blog/index.md new file mode 100644 index 0000000000..635a08fc3b --- /dev/null +++ b/src/content/blog/index.md @@ -0,0 +1,9 @@ +--- +title: "The Dart Blog" +description: "The official blog of the Dart team." +layout: docs +sidenav: "" +showBreadcrumbs: false +--- + + diff --git a/src/content/blog/jaimes-build-context/index.md b/src/content/blog/jaimes-build-context/index.md new file mode 100644 index 0000000000..23561fa6ca --- /dev/null +++ b/src/content/blog/jaimes-build-context/index.md @@ -0,0 +1,97 @@ +--- +title: "Jaime’s build context: A Flutter developer’s thoughts about Antigravity + Tips!" +date: 2025-12-09 +author: "Jaime Wren" +description: "Hi, I'm Jaime Wren, a long-time developer tooling software engineer on the Flutter team. I've seen many questions about BuildContext, so I thought I'd share my thoughts." +layout: docs +sidenav: "" +image: jaimes-build-context-banner.jpeg +category: spotlight +github_handle: "jwren" +--- + +![Antigravity in IDE](jaimes-build-context-banner.jpeg) + +Hi, I’m Jaime Wren, a long-time developer tooling software engineer on the Flutter team. I’ve seen many shifts in the software industry over the years, but the whole industry has just stepped into a new Wild West at breakneck speed and this is both exciting and nerve-wracking. + +In my opinion, we developers are complicated creatures driven by two forces: the need for productivity with our tasks and our inherent joy of programming itself. We thrive when we can slip into a fast, uninterrupted loop. To realize our full potential as developers, we need our entire toolchain to come together without introducing friction. As we transition into the Agentic Era, this need for a cohesive loop is non-negotiable; this is where Flutter has a distinct advantage. + +I’ve tested a lot of AI tools these last few years, waiting for something that actually will save Flutter developers time rather than just shifting where time is spent. Nothing turned the corner for me until [Antigravity](https://antigravity.google/), Google’s new Agentic IDE. It bridges the gap between raw model capability and actual engineering utility. I can finally see all of the infrastructure and tooling around the LLMs coming together to remove friction, rather than adding to it. + +From my own testing, Flutter isn’t just “compatible” with AI tools like Antigravity; it is uniquely equipped to power them. Why? It comes down to Flutter’s strict structures and robust tooling. A core philosophy of Antigravity is a reliance on verification to know if a piece of code actually works. Flutter’s tools provide the immediate feedback that Antigravity’s agents need to validate these actions. All it takes to get started are a few settings to [configure](https://docs.flutter.dev/ai/mcp-server#antigravity) extensions and the MCP server. + +## Some cool things you can do + +There are several cool things that I and other members of the Flutter community have learned to do with Antigravity to speed our development process. I’ve listed a few of them below, but I request that if you have any additional tips, please add them in the comments attached to this post. + +### Run tests & fix the problematic code + +You can use Antigravity to run existing tests, fix any problematic code that is breaking your tests, and then verify that the tests are passing again. Here is the prompt that I use: + +``` +Execute the full test suite with `flutter test`. If failures occur, +fix them one by one. After applying each fix, re-run the full suite one +last time to ensure no regressions. +``` + +### Fix errors and warnings + +To prepare a PR before pushing it, you can use Antigravity to fix your errors, warnings and lints. Here is my prompt for this: + +``` +Run `flutter analyze` over my project. If it fails, fix any errors and warnings, then validate that they are fixed. +``` + +### Discover lints and use them + +There are a lot of lints out there but I don’t have time to research them all and figure out which ones are best for each project. I discovered that this cool prompt helps Antigravity quickly scan my project, suggest, and enable lints that make sense for it: + +``` +Read https://dart.dev/tools/linter-rules and identify rules I am +implicitly following but haven't enabled. Add them to analysis_options.yaml, +run flutter analyze, and fix any resulting violations. +``` + +### Discover good pub.dev packages for my project + +There are tons of pub.dev packages out there, and like the lints, researching them takes time and effort. I wanted a way to figure out which ones might be best for my projects while not leaving my IDE. This prompt worked well for me: + +``` +I need preferences to be shared from one instance of my app to the next, +please go search for this feature in https://pub.dev/, then run +`flutter pub get`, validate that the pub status is in a good status. +Then, find an example in my project to use this new package, +fix any issues that appear from `flutter analyze`, and fix them. +Finally, add a test for the new feature. +``` + +## Thoughts about my journey with Flutter over the years and why I’m excited + +I remember when Flutter entered the scene about 10 years ago. From the beginning and in my opinion, Flutter brought together the right language, framework and tools to be usable-by-default. An often overlooked factor in Flutter’s success is that these pieces weren’t bolted together from different vendors. Instead, the framework, rendering engine, CLI tooling, IDE tooling, language and ecosystem have always been driven by the same organization that has enabled these teams to coordinate to enable the usable-by-default cohesive vision. It’s because of this that I believe there is a “I finally get Hot Reload” moment for every new Flutter developer that makes them excited and hopeful because: + +- The Flutter infrastructure is capable of patching code in milliseconds. +- The errors and warnings appear as you type and ensure that code is valid before a save occurs. +- The language helps prevent syntax errors and frictions. +- Obvious overflow errors are displayed as yellow and black stripes that signal layout issues that need to be addressed. + +In the past year, the promise of generative AI in software development has been akin to the Wild West–demos and products have shown a lot of progress, but have not followed the usable-by-default mantra that has been core to Flutter’s success. With model improvements such as larger context windows that allow a model to actually read a project, and with concepts in the space converging, frameworks like Flutter have had an entry point to add value to agents and workflows. The Flutter team followed suit this year with the [GenUI](https://docs.flutter.dev/ai/genui) effort, the [Dart and Flutter MCP Server](https://docs.flutter.dev/ai/mcp-server), and [AI rules](https://docs.flutter.dev/ai/ai-rules) suggestions. + +Now, with Antigravity entering the arena, we have something that is more than a chatbot built into a window in your IDE. Antigravity inhabits the IDE, running commands and using knowledge about other Flutter and Dart projects to correctly follow your direction and take actions on your behalf in the project space. + +As Flutter engineers, we take for granted the ability to know how to immediately run static analysis (dart analyze) or launch an app (flutter run) in a project we’ve never seen before. While none of these restrictions have limited what developers could create with Flutter, the natural consequence with LLMs and agentic tooling is that our collective uniformity gives these interfaces a huge leap forward in understanding and acting on the structure of our projects. + +``` +dart analyze +``` + +``` +flutter run +``` + +To reap the benefits of LLM tooling, agents require a verification through a positive feedback loop. If a model is producing value on every tenth piece of output and hallucinating the other nine instances, I might be amused, but I won’t be using the tool again. With Antigravity, verification is a core philosophy designed to give agents a feedback loop, allowing them to iterate before finishing work. This is where the benefit of existing robust Flutter tools comes into play, when using Antigravity to assist in writing code, the agent literally iterates until analysis is clean, formatting is consistent, tests pass, and the output from `flutter run` confirms that pixels are being drawn. + +## In summary + +Without a doubt, these new AI tools are redefining how we identify as productive developers who enjoy the practice of programming. Equally without doubt, a robust, fast, uninterrupted loop will continue to be the centerpiece of that experience. + +Antigravity doesn’t replace the programmer; it removes the drudgery that stands between your idea and reality. The loop is getting faster, the ramp is already built, and the future of Flutter is more usable — and more joyful — than ever. diff --git a/src/content/blog/jaimes-build-context/jaimes-build-context-banner.jpeg b/src/content/blog/jaimes-build-context/jaimes-build-context-banner.jpeg new file mode 100644 index 0000000000..c308a04076 Binary files /dev/null and b/src/content/blog/jaimes-build-context/jaimes-build-context-banner.jpeg differ diff --git a/src/content/blog/test-folder-post/dash.png b/src/content/blog/test-folder-post/dash.png new file mode 100755 index 0000000000..df8e789b0a Binary files /dev/null and b/src/content/blog/test-folder-post/dash.png differ diff --git a/src/content/blog/test-folder-post/index.md b/src/content/blog/test-folder-post/index.md new file mode 100644 index 0000000000..4efa15de7e --- /dev/null +++ b/src/content/blog/test-folder-post/index.md @@ -0,0 +1,16 @@ +--- +title: "Test Folder Post" +date: 2025-12-13 +author: "Test Author" +description: "Testing blog post in a subfolder with co-located image: dash.png" +layout: docs +sidenav: "" +image: dash.png +category: tutorials +github_handle: "dash" +--- + +Tthis post is located in a subfolder: `src/content/blog/test-folder-post/`. +It references an image in the same folder: `dash.png`. + +![Dash](dash.png)