Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
59af3f3
initial checkin
ericwindmill Aug 11, 2026
675e7ac
Merge branch 'main' of https://github.com/flutter/website into ide-co…
ericwindmill Aug 11, 2026
78a396d
fix filename
ericwindmill Aug 11, 2026
66d2e40
checkin some refactoring
ericwindmill Aug 11, 2026
ae00cb1
rework data format
ericwindmill Aug 12, 2026
485f2ab
start refactor
ericwindmill Aug 13, 2026
022ccaf
start refactor
ericwindmill Aug 13, 2026
abac75d
more refactor
ericwindmill Aug 13, 2026
9fce03b
more refactor
ericwindmill Aug 13, 2026
d8b7734
more refactor
ericwindmill Aug 13, 2026
d13168d
more refactor
ericwindmill Aug 13, 2026
1862e74
more refactor
ericwindmill Aug 13, 2026
1373068
more refactor
ericwindmill Aug 13, 2026
f083b82
tidy scss
ericwindmill Aug 13, 2026
e944483
fix scss funkiness
ericwindmill Aug 14, 2026
95d3c93
more refactor
ericwindmill Aug 14, 2026
6eb79db
more refactor
ericwindmill Aug 14, 2026
2ff76c9
more refactor
ericwindmill Aug 15, 2026
68a0f0c
Merge branch 'main' of https://github.com/flutter/website into ide-co…
ericwindmill Aug 15, 2026
63e6fdc
Merge branch 'main' of https://github.com/flutter/website into ide-co…
ericwindmill Aug 18, 2026
e7c3351
address code review
ericwindmill Aug 18, 2026
433d743
Merge branch 'main' of https://github.com/flutter/website into ide-co…
ericwindmill Aug 20, 2026
3d280b1
Merge branch 'main' of https://github.com/flutter/website into ide-co…
ericwindmill Aug 20, 2026
a1d8866
oops
ericwindmill Aug 20, 2026
cc7535e
Merge branch 'main' into ide-component
ericwindmill Aug 20, 2026
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
533 changes: 533 additions & 0 deletions packages/site_shared/lib/_sass/components/_ide-explorer.scss

Large diffs are not rendered by default.

Comment thread
ericwindmill marked this conversation as resolved.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import 'package:jaspr/jaspr.dart';
import 'package:jaspr_content/jaspr_content.dart';

import '../../../util.dart';
import 'ide_explorer.dart';

/// A custom markdown component that parses `<IdeExplorer>` and its
/// `<IdeProjectRoot>`, `<IdeFolder>`, and `<IdePage>` children. Defers
/// building the IDE html to the [IdeExplorer] component.
class IdeExplorerMarkdownComponent extends CustomComponent {
const IdeExplorerMarkdownComponent() : super.base();

// Tag name constants
static const String _tagIdeExplorer = 'IdeExplorer';
static const String _tagIdeProjectRoot = 'IdeProjectRoot';
static const String _tagIdeFolder = 'IdeFolder';
static const String _tagIdePage = 'IdePage';

// Attribute name constants
static const String _attrId = 'id';
static const String _attrLabel = 'label';
static const String _attrIsDefaultPage = 'is-default-page';
static const String _attrStartsClosed = 'starts-closed';
static const String _attrBadge = 'badge';
static const String _attrBadgeColor = 'badge-color';
static const String _attrSubtitle = 'subtitle';

// Default values
static const String _defaultRootPrefix = 'root';
static const String _defaultNodePrefix = 'node';

@override
Component? create(Node node, NodesBuilder builder) {
if (node is! ElementNode || node.tag != _tagIdeExplorer) {
return null;
}

final projectRootElements = node.children
?.whereType<ElementNode>()
.where((n) => n.tag == _tagIdeProjectRoot)
.toList();

if (projectRootElements == null || projectRootElements.isEmpty) {
print(
'[ERROR] <$_tagIdeExplorer> requires at '
'least one <$_tagIdeProjectRoot> child element.',
);
return const Component.empty();
}

final customContents = <String, Component>{};
final roots = <IdeExplorerProjectRoot>[];
for (final (index, rootEl) in projectRootElements.indexed) {
final rootId = _generateNodeId(
rootEl.attributes,
_defaultRootPrefix,
index,
);
roots.add(
IdeExplorerProjectRoot(
id: rootId,
label: rootEl.attributes[_attrLabel] ?? '',
children: _parseTreeNodes(
rootEl.children,
builder,
customContents,
rootId,
),
),
);
}

return IdeExplorer(
roots: roots,
customContents: customContents,
);
}

/// Generates a node ID from attributes or creates a default one.
String _generateNodeId(
Map<String, String> attributes,
String prefix,
int index, [
String? parentId,
]) {
if (attributes[_attrId] != null) {
return attributes[_attrId]!;
}
final label = attributes[_attrLabel];
final localId = (label != null && label.isNotEmpty)
? slugify(label)
: '$prefix-$index';
return parentId != null ? '$parentId-$localId' : localId;
}
Comment thread
ericwindmill marked this conversation as resolved.

List<IdeTreeNode> _parseTreeNodes(
List<Node>? nodes,
NodesBuilder builder,
Map<String, Component> customContents,
String parentId,
) {
if (nodes == null || nodes.isEmpty) return const [];

final result = <IdeTreeNode>[];

for (final (index, child) in nodes.whereType<ElementNode>().indexed) {
if (child.tag != _tagIdeFolder && child.tag != _tagIdePage) {
continue;
}

final treeNode = _buildTreeNodeFromElement(
child,
index,
builder,
customContents,
parentId,
);

result.add(treeNode);
}

return result;
}
Comment thread
ericwindmill marked this conversation as resolved.

/// Builds a single [IdeTreeNode] from an [ElementNode].
IdeTreeNode _buildTreeNodeFromElement(
ElementNode element,
int index,
NodesBuilder builder,
Map<String, Component> customContents,
String parentId,
) {
final attributes = element.attributes;
final label = attributes[_attrLabel] ?? '';
final id = _generateNodeId(attributes, _defaultNodePrefix, index, parentId);

// Parse boolean attributes
final isDefaultPage = _getBoolAttribute(
attributes,
_attrIsDefaultPage,
defaultValue: false,
);
final startsClosed = _getBoolAttribute(
attributes,
_attrStartsClosed,
defaultValue: true,
);

// Parse badge attributes
final badge = attributes[_attrBadge];
final badgeColor = attributes[_attrBadgeColor] != null
? IdeBadgeColor.fromString(attributes[_attrBadgeColor])
: null;

final subtitle = attributes[_attrSubtitle];

// Recursively parse children
final nestedTreeNodes = _parseTreeNodes(
element.children,
builder,
customContents,
id,
);
Comment thread
ericwindmill marked this conversation as resolved.

// Extract and store custom body content if present
_storeCustomContentIfPresent(
element.children,
id,
builder,
customContents,
);

return IdeTreeNode(
id: id,
label: label,
isDefaultPage: isDefaultPage,
startsClosed: startsClosed,
badge: badge,
badgeColor: badgeColor,
subtitle: subtitle,
children: nestedTreeNodes,
);
}

/// Parses a boolean attribute value, returning [defaultValue] if not present.
bool _getBoolAttribute(
Map<String, String> attributes,
String key, {
required bool defaultValue,
}) {
final value = attributes[key];
return value != null ? value == 'true' : defaultValue;
}

/// Checks if a node represents body content (not a folder/page structure).
bool _isBodyContent(Node node) {
if (node is ElementNode &&
(node.tag == _tagIdeFolder || node.tag == _tagIdePage)) {
return false;
}
if (node is TextNode && node.text.trim().isEmpty) {
return false;
}
return true;
}

/// Extracts non-structural content nodes from children.
List<Node> _extractContentNodes(List<Node> children) {
return children
.where((n) {
if (n is ElementNode &&
(n.tag == _tagIdeFolder || n.tag == _tagIdePage)) {
return false;
}
return true;
})
.toList(growable: false);
}

/// Stores custom body content for a node if it exists.
void _storeCustomContentIfPresent(
List<Node>? children,
String nodeId,
NodesBuilder builder,
Map<String, Component> customContents,
) {
if (!_hasCustomBodyContent(children)) {
return;
}

final contentNodes = _extractContentNodes(children!);
if (contentNodes.isNotEmpty) {
customContents[nodeId] = builder.build(contentNodes);
}
}

/// Checks if a node has custom body content.
bool _hasCustomBodyContent(List<Node>? children) {
return children?.any(_isBodyContent) ?? false;
}
}
1 change: 1 addition & 0 deletions sites/docs/lib/_sass/_site.scss
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
@use 'package:site_shared/_sass/components/code';
@use 'package:site_shared/_sass/components/cookie-notice';
@use 'package:site_shared/_sass/components/dropdown';
@use 'package:site_shared/_sass/components/ide-explorer';
@use 'package:site_shared/_sass/components/menu-toggle';
@use 'package:site_shared/_sass/components/progress-ring';
@use 'package:site_shared/_sass/components/quiz';
Expand Down
2 changes: 2 additions & 0 deletions sites/docs/lib/main.server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:jaspr_content/jaspr_content.dart';
import 'package:jaspr_content/theme.dart';
import 'package:path/path.dart' as path;
import 'package:site_shared/components/common/card.dart';
import 'package:site_shared/components/common/ide_explorer/markdown_component.dart';
import 'package:site_shared/components/common/material_icon.dart';
import 'package:site_shared/components/common/tabs.dart';
import 'package:site_shared/components/common/youtube_embed.dart';
Expand Down Expand Up @@ -99,6 +100,7 @@ List<CustomComponent> get _embeddableComponents => [
const CodePreview(),
const YoutubeEmbed(),
const FileTree(),
const IdeExplorerMarkdownComponent(),
const Quiz(),
const ProgressRing(),
const SummaryCard(),
Expand Down
119 changes: 119 additions & 0 deletions sites/docs/lib/src/client/global_scripts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ void setUpSite() {
_setUpPlatformKeys();
_setUpToc();
_setUpSteppers();
_setUpIdeExplorers();
}

void _setUpSearchKeybindings() {
Expand Down Expand Up @@ -477,3 +478,121 @@ void _scrollTo(web.Element element, {required bool smooth}) {
),
);
}

/// Set up interactivity of the file/detail explorer created with
/// the `<IdeExplorer>` custom component.
void _setUpIdeExplorers() {
final explorers = web.document.querySelectorAll('.ide-explorer');
for (var i = 0; i < explorers.length; i++) {
_setUpIdeExplorer(explorers.item(i) as web.Element);
}
}

void _setUpIdeExplorer(web.Element explorer) {
void selectIdeNode(String domId) {
final selectTargets = explorer.querySelectorAll('[data-ide-select]');
web.Element? sidebarTarget;
for (var i = 0; i < selectTargets.length; i++) {
final target = selectTargets.item(i) as web.Element;
final isMatch = target.getAttribute('data-ide-select') == domId;
target.classList.toggle('active', isMatch);
if (isMatch && target.closest('.ide-tree') != null) {
sidebarTarget = target;
}
}

final panels = explorer.querySelectorAll('[data-ide-panel]');
for (var i = 0; i < panels.length; i++) {
final panel = panels.item(i) as web.Element;
panel.classList.toggle(
'active',
panel.getAttribute('data-ide-panel') == domId,
);
}

// Expand every ancestor folder so the selected item stays visible.
final ownDetails = sidebarTarget?.closest('details');
final isFolderSelf =
sidebarTarget?.parentElement?.tagName.toLowerCase() == 'summary';
var current = isFolderSelf ? ownDetails?.parentElement : ownDetails;
Comment thread
ericwindmill marked this conversation as resolved.
while (current != null) {
final ancestorDetails = current.closest('details');
if (ancestorDetails == null) break;
(ancestorDetails as web.HTMLDetailsElement).open = true;
current = ancestorDetails.parentElement;
}
}

void switchIdeRoot(String rootId) {
final tabs = explorer.querySelectorAll('.ide-root-tab');
for (var i = 0; i < tabs.length; i++) {
final tab = tabs.item(i) as web.Element;
tab.classList.toggle(
'active',
tab.getAttribute('data-ide-root') == rootId,
);
}

final trees = explorer.querySelectorAll('.ide-tree');
web.Element? activeTree;
for (var i = 0; i < trees.length; i++) {
final tree = trees.item(i) as web.Element;
final isMatch = tree.getAttribute('data-ide-root') == rootId;
tree.classList.toggle('active', isMatch);
if (isMatch) activeTree = tree;
}

final firstDomId = activeTree
?.querySelector('[data-ide-select]')
?.getAttribute('data-ide-select');
if (firstDomId != null) {
selectIdeNode(firstDomId);
}
}

void toggleAllIdeFolders() {
final activeTree =
explorer.querySelector('.ide-tree.active') ??
explorer.querySelector('.ide-tree');
if (activeTree == null) return;

final allDetails = activeTree.querySelectorAll('details');
var anyClosed = false;
for (var i = 0; i < allDetails.length; i++) {
if (!(allDetails.item(i) as web.HTMLDetailsElement).open) {
anyClosed = true;
break;
}
}

for (var i = 0; i < allDetails.length; i++) {
(allDetails.item(i) as web.HTMLDetailsElement).open = anyClosed;
}
}

void handleClick(web.Event event) {
final target = event.target as web.Element?;
if (target == null) return;

final selectTarget = target.closest('[data-ide-select]');
if (selectTarget != null) {
final domId = selectTarget.getAttribute('data-ide-select');
if (domId != null) selectIdeNode(domId);
event.preventDefault();
return;
}

final rootTab = target.closest('.ide-root-tab');
if (rootTab != null) {
final rootId = rootTab.getAttribute('data-ide-root');
if (rootId != null) switchIdeRoot(rootId);
return;
}

if (target.closest('[data-ide-toggle-all]') != null) {
toggleAllIdeFolders();
}
}

explorer.addEventListener('click', handleClick.toJS);
}
Loading
Loading