Repository: dart-lang/dartdoc (dart)

Issue:
Dart 3 class modifiers should be clearly visible in dartdocs
Dart 3 apps new class modifiers (https://github.com/dart-lang/language/issues/2242), and the core libraries have been updated to many cases to use these. For example, the `platform` class in `dart:io` is now [`abstract final`](https://github.com/dart-lang/sdk/blob/main/sdk/lib/io/platform.dart#L61).

In the current DartDocs it's very hard to see these modifiers (e.g. [platform](https://api.dart.dev/beta/3.0.0-417.2.beta/dart-io/Platform-class.html)). They are shown just textually near the top:

<img width="1051" alt="Screenshot 2023-04-21 at 13 39 37" src="https://user-images.githubusercontent.com/13644170/233626856-fef1f31b-b130-45b5-8fcc-b8d2dce262fb.png">

I'd like to suggest the following improvements to make class modifiers more clear:

1. Show capability chips after the class name (similar to the chips we show on API members in the rest of the page):
<img width="418" alt="Screenshot 2023-04-21 at 13 43 58" src="https://user-images.githubusercontent.com/13644170/233627605-883b1078-b933-475b-b8ae-70d6e1597d67.png">

2. On generative constructors, show them as now available (e.g. using ~~strikethrough~~ text) with capability chips detailing why. From @lrhn the combinations where the constructor isn't available are: a) `sealed`, b) `abstract final`, and c) `abstract interface`).

Pull request:
Fixes #3392.

This adds chips indicating the new class modifier features (as well as adding 'abstract') to each class page.   Class modifiers and `abstract` are now removed from the breadcrumbs.

The implementation reuses parts of the old "language feature" system that we had used for displaying "Null safety" chips everywhere -- fortunately that infrastructure is still in place so we do not have to alter the templates.   We should still rename and perhaps reorganize that system a bit:  #3400.

Sample screenshot:
![Screenshot 2023-04-27 at 8 46 42 AM](https://user-images.githubusercontent.com/14116827/234928304-93e4bdbb-1bd0-48c9-ae1c-de09717a5919.png)

Patch:
diff --git a/lib/resources/styles.css b/lib/resources/styles.css
index 33c0244cdd..09b85b5188 100644
--- a/lib/resources/styles.css
+++ b/lib/resources/styles.css
@@ -550,7 +550,7 @@ h1 .category {
   vertical-align: middle;
 }
 
-/* The badge under a declaration for things like "const", "read-only", etc. and for the badges inline like Null safety*/
+/* The badge under a declaration for things like "const", "read-only", etc. and for the badges inline like sealed or interface */
 /* See https://github.com/dart-lang/dartdoc/blob/main/lib/src/model/feature.dart */
 .feature {
   display: inline-block;
@@ -570,6 +570,7 @@ a.feature:hover {
 
 h1 .feature {
   vertical-align: middle;
+  margin: 0 -2px 0 0;
 }
 
 .source-link {
diff --git a/lib/src/generator/templates.runtime_renderers.dart b/lib/src/generator/templates.runtime_renderers.dart
index 8da62b49db..cf3fa01b08 100644
--- a/lib/src/generator/templates.runtime_renderers.dart
+++ b/lib/src/generator/templates.runtime_renderers.dart
@@ -6867,6 +6867,19 @@ class _Renderer_InheritingContainer extends RendererBase<InheritingContainer> {
                         parent: r);
                   },
                 ),
+                'displayedLanguageFeatures': Property(
+                  getValue: (CT_ c) => c.displayedLanguageFeatures,
+                  renderVariable: (CT_ c, Property<CT_> self,
+                          List<String> remainingNames) =>
+                      self.renderSimpleVariable(
+                          c, remainingNames, 'List<LanguageFeature>'),
+                  renderIterable: (CT_ c, RendererBase<CT_> r,
+                      List<MustachioNode> ast, StringSink sink) {
+                    return c.displayedLanguageFeatures.map((e) =>
+                        _render_LanguageFeature(e, ast, r.template, sink,
+                            parent: r));
+                  },
+                ),
                 'element': Property(
                   getValue: (CT_ c) => c.element,
                   renderVariable: (CT_ c, Property<CT_> self,
diff --git a/lib/src/model/container_modifiers.dart b/lib/src/model/container_modifiers.dart
index 94ebf08127..6472054e55 100644
--- a/lib/src/model/container_modifiers.dart
+++ b/lib/src/model/container_modifiers.dart
@@ -2,7 +2,8 @@
 // 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 'package:collection/collection.dart' show IterableExtension;
+import 'package:dartdoc/src/model/language_feature.dart';
+import 'package:dartdoc/src/render/language_feature_renderer.dart';
 
 /// Represents a single modifier applicable to containers.
 class ContainerModifier implements Comparable<ContainerModifier> {
@@ -15,6 +16,7 @@ class ContainerModifier implements Comparable<ContainerModifier> {
 
   /// The display order of this modifier.
   final int order;
+
   const ContainerModifier._(
     this.name, {
     required this.order,
@@ -41,10 +43,11 @@ class ContainerModifier implements Comparable<ContainerModifier> {
   static const ContainerModifier mixin = ContainerModifier._('mixin', order: 4);
 }
 
-extension ContainerModifiers on Iterable<ContainerModifier> {
-  /// Returns a string suitable for prefixing the class name in Dartdoc
-  /// title bars based on given modifiers.
-  String modifiersAsFullKindPrefix() => sorted((a, b) => a.compareTo(b))
-      .where((m) => !m.hideIfPresent.any(contains))
-      .join(' ');
+extension BuildLanguageFeatureSet on Iterable<ContainerModifier> {
+  /// Transforms [ContainerModifiers] into a series of [LanguageFeature] objects
+  /// suitable for rendering as chips.   Assumes iterable is sorted.
+  Iterable<LanguageFeature> asLanguageFeatureSet(
+          LanguageFeatureRenderer languageFeatureRenderer) =>
+      where((m) => !m.hideIfPresent.any(contains))
+          .map((m) => LanguageFeature(m.name, languageFeatureRenderer));
 }
diff --git a/lib/src/model/inheriting_container.dart b/lib/src/model/inheriting_container.dart
index 756d6524f2..9b049068eb 100644
--- a/lib/src/model/inheriting_container.dart
+++ b/lib/src/model/inheriting_container.dart
@@ -9,6 +9,7 @@ import 'package:dartdoc/src/element_type.dart';
 import 'package:dartdoc/src/model/comment_referable.dart';
 import 'package:dartdoc/src/model/container_modifiers.dart';
 import 'package:dartdoc/src/model/extension_target.dart';
+import 'package:dartdoc/src/model/language_feature.dart';
 import 'package:dartdoc/src/model/model.dart';
 import 'package:dartdoc/src/model_utils.dart' as model_utils;
 import 'package:meta/meta.dart';
@@ -87,14 +88,21 @@ abstract class InheritingContainer extends Container
   /// Class modifiers from the Dart feature specification.
   ///
   /// These apply to or have some meaning for [Class]es and [Mixin]s.
-  late List<ContainerModifier> containerModifiers = [
+  late final List<ContainerModifier> containerModifiers = [
     if (isAbstract) ContainerModifier.abstract,
     if (isSealed) ContainerModifier.sealed,
     if (isBase) ContainerModifier.base,
     if (isInterface) ContainerModifier.interface,
     if (isFinal) ContainerModifier.finalModifier,
     if (isMixinClass) ContainerModifier.mixin,
-  ];
+  ]..sort();
+
+  @override
+  late final List<LanguageFeature> displayedLanguageFeatures =
+      containerModifiers
+          .asLanguageFeatureSet(
+              packageGraph.rendererFactory.languageFeatureRenderer)
+          .toList();
 
   late final List<ModelElement> _allModelElements = [
     ...super.allModelElements,
@@ -267,12 +275,7 @@ abstract class InheritingContainer extends Container
   @override
   Library get enclosingElement => library;
 
-  String get fullkind {
-    if (containerModifiers.isNotEmpty) {
-      return '${containerModifiers.modifiersAsFullKindPrefix()} $kind';
-    }
-    return kind;
-  }
+  String get fullkind => kind;
 
   @override
   bool get hasModifiers =>
diff --git a/lib/src/model/language_feature.dart b/lib/src/model/language_feature.dart
index 0813f2ac0e..41df7e5013 100644
--- a/lib/src/model/language_feature.dart
+++ b/lib/src/model/language_feature.dart
@@ -4,12 +4,33 @@
 
 import 'package:dartdoc/src/render/language_feature_renderer.dart';
 
-const Map<String, String> _featureDescriptions = {};
-
-const Map<String, String> _featureUrls = {};
-
-/// An abstraction for a language feature; used to render tags to notify
-/// the user that the documentation should be specially interpreted.
+const Map<String, String> _featureDescriptions = {
+  'sealed': 'All direct subtypes must be defined in the same library.',
+  'abstract': 'This type can not be directly constructed.',
+  'base': 'This type can only be extended (not implemented or mixed in).',
+  'interface': 'This type can only be implemented (not extended or mixed in).',
+  'final': 'This type can neither be extended, implemented, nor mixed in.',
+  'mixin': 'This type can be used as a class and a mixin.',
+};
+
+const Map<String, String> _featureUrls = {
+  // TODO(jcollins-g): link to dart.dev for all links once documentation is
+  // available.
+  'sealed':
+      'https://github.com/dart-lang/language/blob/main/accepted/future-releases/sealed-types/feature-specification.md#sealed-types',
+  'abstract': 'https://dart.dev/language/classes#abstract-classes',
+  'base':
+      'https://github.com/dart-lang/language/blob/main/accepted/future-releases/class-modifiers/feature-specification.md#class-modifiers',
+  'interface':
+      'https://github.com/dart-lang/language/blob/main/accepted/future-releases/class-modifiers/feature-specification.md#class-modifiers',
+  'final':
+      'https://github.com/dart-lang/language/blob/main/accepted/future-releases/class-modifiers/feature-specification.md#class-modifiers',
+  'mixin':
+      'https://github.com/dart-lang/language/blob/main/accepted/future-releases/class-modifiers/feature-specification.md#class-modifiers',
+};
+
+/// An abstraction for a language feature; used to render tags ('chips') to
+/// notify the user that the documentation should be specially interpreted.
 class LanguageFeature {
   /// The description of this language feature.
   String? get featureDescription => _featureDescriptions[name];


Test patch:
diff --git a/test/class_modifiers_test.dart b/test/class_modifiers_test.dart
index e2c2dabf43..9d8bd8bc32 100644
--- a/test/class_modifiers_test.dart
+++ b/test/class_modifiers_test.dart
@@ -2,12 +2,18 @@
 // 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 'package:dartdoc/src/model/model.dart';
 import 'package:test/test.dart';
 import 'package:test_reflective_loader/test_reflective_loader.dart';
 
 import 'dartdoc_test_base.dart';
 import 'src/utils.dart';
 
+extension on InheritingContainer {
+  String languageFeatureChips() =>
+      displayedLanguageFeatures.map((l) => l.name).join(' ');
+}
+
 void main() {
   defineReflectiveSuite(() {
     if (classModifiersAllowed) {
@@ -63,21 +69,21 @@ base mixin O {}
     var Mclass = library.classes.named('M');
     var Nmixin = library.mixins.named('N');
     var Omixin = library.mixins.named('O');
-    expect(Aclass.fullkind, equals('class'));
-    expect(Bclass.fullkind, equals('base class'));
-    expect(Cclass.fullkind, equals('interface class'));
-    expect(Dclass.fullkind, equals('final class'));
-    expect(Eclass.fullkind, equals('sealed class'));
-    expect(Fclass.fullkind, equals('abstract class'));
-    expect(Gclass.fullkind, equals('abstract base class'));
-    expect(Hclass.fullkind, equals('abstract interface class'));
-    expect(Iclass.fullkind, equals('abstract final class'));
-    expect(Jclass.fullkind, equals('mixin class'));
-    expect(Kclass.fullkind, equals('base mixin class'));
-    expect(Lclass.fullkind, equals('abstract mixin class'));
-    expect(Mclass.fullkind, equals('abstract base mixin class'));
-    expect(Nmixin.fullkind, equals('mixin'));
-    expect(Omixin.fullkind, equals('base mixin'));
+    expect(Aclass.languageFeatureChips(), equals(''));
+    expect(Bclass.languageFeatureChips(), equals('base'));
+    expect(Cclass.languageFeatureChips(), equals('interface'));
+    expect(Dclass.languageFeatureChips(), equals('final'));
+    expect(Eclass.languageFeatureChips(), equals('sealed'));
+    expect(Fclass.languageFeatureChips(), equals('abstract'));
+    expect(Gclass.languageFeatureChips(), equals('abstract base'));
+    expect(Hclass.languageFeatureChips(), equals('abstract interface'));
+    expect(Iclass.languageFeatureChips(), equals('abstract final'));
+    expect(Jclass.languageFeatureChips(), equals('mixin'));
+    expect(Kclass.languageFeatureChips(), equals('base mixin'));
+    expect(Lclass.languageFeatureChips(), equals('abstract mixin'));
+    expect(Mclass.languageFeatureChips(), equals('abstract base mixin'));
+    expect(Nmixin.languageFeatureChips(), equals(''));
+    expect(Omixin.languageFeatureChips(), equals('base'));
   }
 
   void test_abstractSealed() async {
@@ -86,7 +92,8 @@ abstract class A {}
 sealed class B extends A {}
 ''');
     var Bclass = library.classes.named('B');
-    expect(Bclass.fullkind, equals('sealed class')); // *not* sealed abstract
+    expect(Bclass.languageFeatureChips(),
+        equals('sealed')); // *not* sealed abstract
   }
 
   void test_inferredModifiers() async {
@@ -116,13 +123,14 @@ base class M extends L {}
     var Iclass = library.classes.named('I');
     var Lclass = library.classes.named('L');
     var Mclass = library.classes.named('M');
-    expect(Bclass.fullkind, equals('sealed class')); // *not* sealed base
-    expect(Cclass.fullkind, equals('base class'));
-    expect(Eclass.fullkind, equals('sealed class'));
-    expect(Fclass.fullkind, equals('interface class'));
-    expect(Hclass.fullkind, equals('sealed class'));
-    expect(Iclass.fullkind, equals('final class'));
-    expect(Lclass.fullkind, equals('sealed class'));
-    expect(Mclass.fullkind, equals('base class'));
+    expect(
+        Bclass.languageFeatureChips(), equals('sealed')); // *not* sealed base
+    expect(Cclass.languageFeatureChips(), equals('base'));
+    expect(Eclass.languageFeatureChips(), equals('sealed'));
+    expect(Fclass.languageFeatureChips(), equals('interface'));
+    expect(Hclass.languageFeatureChips(), equals('sealed'));
+    expect(Iclass.languageFeatureChips(), equals('final'));
+    expect(Lclass.languageFeatureChips(), equals('sealed'));
+    expect(Mclass.languageFeatureChips(), equals('base'));
   }
 }
diff --git a/test/container_modifiers_test.dart b/test/container_modifiers_test.dart
index b1b77f9dd0..a952a962e9 100644
--- a/test/container_modifiers_test.dart
+++ b/test/container_modifiers_test.dart
@@ -3,27 +3,41 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'package:dartdoc/src/model/container_modifiers.dart';
+import 'package:dartdoc/src/model/language_feature.dart';
+import 'package:dartdoc/src/render/language_feature_renderer.dart';
 import 'package:test/test.dart';
 
+class TestChipRenderer extends LanguageFeatureRenderer {
+  @override
+  String renderLanguageFeatureLabel(LanguageFeature l) => l.name;
+}
+
+extension TestChipsRenderer on Iterable<LanguageFeature> {
+  String asRenderedString() => map((l) => l.featureLabel).join(' ');
+}
+
 void main() {
   group('fullKind string tests', () {
     test('basic', () {
-      var l = {
+      var l = [
         ContainerModifier.base,
         ContainerModifier.interface,
         ContainerModifier.abstract
-      };
-      expect(l.modifiersAsFullKindPrefix(), equals('abstract base interface'));
+      ]..sort();
+      expect(l.asLanguageFeatureSet(TestChipRenderer()).asRenderedString(),
+          equals('abstract base interface'));
     });
 
     test('hide abstract on sealed', () {
-      var l = {ContainerModifier.abstract, ContainerModifier.sealed};
-      expect(l.modifiersAsFullKindPrefix(), equals('sealed'));
+      var l = [ContainerModifier.abstract, ContainerModifier.sealed]..sort();
+      expect(l.asLanguageFeatureSet(TestChipRenderer()).asRenderedString(),
+          equals('sealed'));
     });
 
     test('empty', () {
-      var l = <ContainerModifier>{};
-      expect(l.modifiersAsFullKindPrefix(), equals(''));
+      var l = <ContainerModifier>[];
+      expect(l.asLanguageFeatureSet(TestChipRenderer()).asRenderedString(),
+          equals(''));
     });
   });
 }
diff --git a/test/end2end/model_test.dart b/test/end2end/model_test.dart
index 94a73c9895..2e27dd69be 100644
--- a/test/end2end/model_test.dart
+++ b/test/end2end/model_test.dart
@@ -1991,10 +1991,6 @@ void main() {
       expect(interfaces[1].name, 'E');
     });
 
-    test('class title has abstract keyword', () {
-      expect(Cat.fullkind, 'abstract class');
-    });
-
     test('class title has no abstract keyword', () {
       expect(Dog.fullkind, 'class');
     });