[flutter_plugin_tools] Validate code blocks in readme-check (#5436)
diff --git a/.cirrus.yml b/.cirrus.yml
index c256ab1..fe5a180 100644
--- a/.cirrus.yml
+++ b/.cirrus.yml
@@ -101,7 +101,13 @@
always:
format_script: ./script/tool_runner.sh format --fail-on-change
pubspec_script: ./script/tool_runner.sh pubspec-check
- readme_script: ./script/tool_runner.sh readme-check
+ readme_script:
+ - ./script/tool_runner.sh readme-check
+ # Re-run with --require-excerpts, skipping packages that still need
+ # to be converted. Once https://github.com/flutter/flutter/issues/102679
+ # has been fixed, this can be removed and there can just be a single
+ # run with --require-excerpts and no exclusions.
+ - ./script/tool_runner.sh readme-check --require-excerpts --exclude=script/configs/temp_exclude_excerpt.yaml
license_script: dart $PLUGIN_TOOL license-check
- name: federated_safety
# This check is only meaningful for PRs, as it validates changes
diff --git a/packages/camera/camera/README.md b/packages/camera/camera/README.md
index a1c60a0..97b16d2 100644
--- a/packages/camera/camera/README.md
+++ b/packages/camera/camera/README.md
@@ -46,7 +46,7 @@
Change the minimum Android sdk version to 21 (or higher) in your `android/app/build.gradle` file.
-```
+```groovy
minSdkVersion 21
```
diff --git a/packages/espresso/README.md b/packages/espresso/README.md
index 68c3b55..6d66bfb 100644
--- a/packages/espresso/README.md
+++ b/packages/espresso/README.md
@@ -85,13 +85,13 @@
The following command line command runs the test locally:
-```
+```sh
./gradlew app:connectedAndroidTest -Ptarget=`pwd`/../test_driver/example.dart
```
Espresso tests can also be run on [Firebase Test Lab](https://firebase.google.com/docs/test-lab):
-```
+```sh
./gradlew app:assembleAndroidTest
./gradlew app:assembleDebug -Ptarget=<path_to_test>.dart
gcloud auth activate-service-account --key-file=<PATH_TO_KEY_FILE>
diff --git a/packages/file_selector/file_selector/README.md b/packages/file_selector/file_selector/README.md
index 544cde8..89cac1e 100644
--- a/packages/file_selector/file_selector/README.md
+++ b/packages/file_selector/file_selector/README.md
@@ -14,12 +14,12 @@
### macOS
You will need to [add an entitlement][entitlement] for either read-only access:
-```
+```xml
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
```
or read/write access:
-```
+```xml
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
```
diff --git a/packages/file_selector/file_selector_macos/README.md b/packages/file_selector/file_selector_macos/README.md
index efa5272..3241b21 100644
--- a/packages/file_selector/file_selector_macos/README.md
+++ b/packages/file_selector/file_selector_macos/README.md
@@ -17,12 +17,12 @@
### Entitlements
You will need to [add an entitlement][4] for either read-only access:
-```
+```xml
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
```
or read/write access:
-```
+```xml
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
```
diff --git a/packages/google_sign_in/google_sign_in_web/README.md b/packages/google_sign_in/google_sign_in_web/README.md
index 4ee1a29..463603e 100644
--- a/packages/google_sign_in/google_sign_in_web/README.md
+++ b/packages/google_sign_in/google_sign_in_web/README.md
@@ -37,7 +37,7 @@
You can tell `flutter run` to listen for requests in a specific host and port with the following:
-```
+```sh
flutter run -d chrome --web-hostname localhost --web-port 7357
```
diff --git a/packages/url_launcher/url_launcher/README.md b/packages/url_launcher/url_launcher/README.md
index 0cdbe1b..9c9f0b5 100644
--- a/packages/url_launcher/url_launcher/README.md
+++ b/packages/url_launcher/url_launcher/README.md
@@ -46,7 +46,7 @@
Add any URL schemes passed to `canLaunchUrl` as `LSApplicationQueriesSchemes` entries in your Info.plist file.
Example:
-```
+```xml
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
diff --git a/script/configs/temp_exclude_excerpt.yaml b/script/configs/temp_exclude_excerpt.yaml
new file mode 100644
index 0000000..fc8454d
--- /dev/null
+++ b/script/configs/temp_exclude_excerpt.yaml
@@ -0,0 +1,27 @@
+# Packages that have not yet adopted code-excerpt.
+#
+# This only exists to allow incrementally adopting the new requirement.
+# Packages shoud never be added to this list.
+
+# TODO(ecosystem): Remove everything from this list. See
+# https://github.com/flutter/flutter/issues/102679
+- camera_web
+- espresso
+- file_selector/file_selector
+- google_maps_flutter/google_maps_flutter
+- google_sign_in/google_sign_in
+- google_sign_in_web
+- image_picker/image_picker
+- image_picker_for_web
+- in_app_purchase/in_app_purchase
+- ios_platform_images
+- local_auth/local_auth
+- path_provider/path_provider
+- plugin_platform_interface
+- quick_actions/quick_actions
+- shared_preferences/shared_preferences
+- url_launcher/url_launcher
+- video_player/video_player
+- webview_flutter/webview_flutter
+- webview_flutter_android
+- webview_flutter_web
diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md
index 7587ff3..1bce029 100644
--- a/script/tool/CHANGELOG.md
+++ b/script/tool/CHANGELOG.md
@@ -1,3 +1,10 @@
+## 0.8.4
+
+- `readme-check` now validates that there's a info tag on code blocks to
+ identify (and for supported languages, syntax highlight) the language.
+- `readme-check` now has a `--require-excerpts` flag to require that any Dart
+ code blocks be managed by `code_excerpter`.
+
## 0.8.3
- Adds a new `update-excerpts` command to maintain README files using the
diff --git a/script/tool/lib/src/readme_check_command.dart b/script/tool/lib/src/readme_check_command.dart
index 99e271c..9432c4b 100644
--- a/script/tool/lib/src/readme_check_command.dart
+++ b/script/tool/lib/src/readme_check_command.dart
@@ -26,7 +26,12 @@
processRunner: processRunner,
platform: platform,
gitDir: gitDir,
- );
+ ) {
+ argParser.addFlag(_requireExcerptsArg,
+ help: 'Require that Dart code blocks be managed by code-excerpt.');
+ }
+
+ static const String _requireExcerptsArg = 'require-excerpts';
// Standardized capitalizations for platforms that a plugin can support.
static const Map<String, String> _standardPlatformNames = <String, String>{
@@ -61,8 +66,15 @@
final Pubspec pubspec = package.parsePubspec();
final bool isPlugin = pubspec.flutter?['plugin'] != null;
+ final List<String> readmeLines = package.readmeFile.readAsLinesSync();
+
+ final String? blockValidationError = _validateCodeBlocks(readmeLines);
+ if (blockValidationError != null) {
+ errors.add(blockValidationError);
+ }
+
if (isPlugin && (!package.isFederated || package.isAppFacing)) {
- final String? error = _validateSupportedPlatforms(package, pubspec);
+ final String? error = _validateSupportedPlatforms(readmeLines, pubspec);
if (error != null) {
errors.add(error);
}
@@ -73,23 +85,86 @@
: PackageResult.fail(errors);
}
+ /// Validates that code blocks (``` ... ```) follow repository standards.
+ String? _validateCodeBlocks(List<String> readmeLines) {
+ final RegExp codeBlockDelimiterPattern = RegExp(r'^\s*```\s*([^ ]*)\s*');
+ final List<int> missingLanguageLines = <int>[];
+ final List<int> missingExcerptLines = <int>[];
+ bool inBlock = false;
+ for (int i = 0; i < readmeLines.length; ++i) {
+ final RegExpMatch? match =
+ codeBlockDelimiterPattern.firstMatch(readmeLines[i]);
+ if (match == null) {
+ continue;
+ }
+ if (inBlock) {
+ inBlock = false;
+ continue;
+ }
+ inBlock = true;
+
+ final int humanReadableLineNumber = i + 1;
+
+ // Ensure that there's a language tag.
+ final String infoString = match[1] ?? '';
+ if (infoString.isEmpty) {
+ missingLanguageLines.add(humanReadableLineNumber);
+ continue;
+ }
+
+ // Check for code-excerpt usage if requested.
+ if (getBoolArg(_requireExcerptsArg) && infoString == 'dart') {
+ const String excerptTagStart = '<?code-excerpt ';
+ if (i == 0 || !readmeLines[i - 1].trim().startsWith(excerptTagStart)) {
+ missingExcerptLines.add(humanReadableLineNumber);
+ }
+ }
+ }
+
+ String? errorSummary;
+
+ if (missingLanguageLines.isNotEmpty) {
+ for (final int lineNumber in missingLanguageLines) {
+ printError('${indentation}Code block at line $lineNumber is missing '
+ 'a language identifier.');
+ }
+ printError(
+ '\n${indentation}For each block listed above, add a language tag to '
+ 'the opening block. For instance, for Dart code, use:\n'
+ '${indentation * 2}```dart\n');
+ errorSummary = 'Missing language identifier for code block';
+ }
+
+ if (missingExcerptLines.isNotEmpty) {
+ for (final int lineNumber in missingExcerptLines) {
+ printError('${indentation}Dart code block at line $lineNumber is not '
+ 'managed by code-excerpt.');
+ }
+ printError(
+ '\n${indentation}For each block listed above, add <?code-excerpt ...> '
+ 'tag on the previous line, and ensure that a build.excerpt.yaml is '
+ 'configured for the source example.\n');
+ errorSummary ??= 'Missing code-excerpt management for code block';
+ }
+
+ return errorSummary;
+ }
+
/// Validates that the plugin has a supported platforms table following the
/// expected format, returning an error string if any issues are found.
String? _validateSupportedPlatforms(
- RepositoryPackage package, Pubspec pubspec) {
- final List<String> contents = package.readmeFile.readAsLinesSync();
-
+ List<String> readmeLines, Pubspec pubspec) {
// Example table following expected format:
// | | Android | iOS | Web |
// |----------------|---------|----------|------------------------|
// | **Support** | SDK 21+ | iOS 10+* | [See `camera_web `][1] |
- final int detailsLineNumber =
- contents.indexWhere((String line) => line.startsWith('| **Support**'));
+ final int detailsLineNumber = readmeLines
+ .indexWhere((String line) => line.startsWith('| **Support**'));
if (detailsLineNumber == -1) {
return 'No OS support table found';
}
final int osLineNumber = detailsLineNumber - 2;
- if (osLineNumber < 0 || !contents[osLineNumber].startsWith('|')) {
+ if (osLineNumber < 0 || !readmeLines[osLineNumber].startsWith('|')) {
return 'OS support table does not have the expected header format';
}
@@ -111,7 +186,7 @@
final YamlMap platformSupportMaps = platformsEntry as YamlMap;
final Set<String> actuallySupportedPlatform =
platformSupportMaps.keys.toSet().cast<String>();
- final Iterable<String> documentedPlatforms = contents[osLineNumber]
+ final Iterable<String> documentedPlatforms = readmeLines[osLineNumber]
.split('|')
.map((String entry) => entry.trim())
.where((String entry) => entry.isNotEmpty);
diff --git a/script/tool/pubspec.yaml b/script/tool/pubspec.yaml
index 9f9910f..af38193 100644
--- a/script/tool/pubspec.yaml
+++ b/script/tool/pubspec.yaml
@@ -1,7 +1,7 @@
name: flutter_plugin_tools
description: Productivity utils for flutter/plugins and flutter/packages
repository: https://github.com/flutter/plugins/tree/main/script/tool
-version: 0.8.3
+version: 0.8.4
dependencies:
args: ^2.1.0
diff --git a/script/tool/test/readme_check_command_test.dart b/script/tool/test/readme_check_command_test.dart
index aec2fa0..b6e016d 100644
--- a/script/tool/test/readme_check_command_test.dart
+++ b/script/tool/test/readme_check_command_test.dart
@@ -275,4 +275,135 @@
);
});
});
+
+ group('code blocks', () {
+ test('fails on missing info string', () async {
+ final Directory packageDir = createFakePackage('a_package', packagesDir);
+
+ packageDir.childFile('README.md').writeAsStringSync('''
+Example:
+
+```
+void main() {
+ // ...
+}
+```
+''');
+
+ Error? commandError;
+ final List<String> output = await runCapturingPrint(
+ runner, <String>['readme-check'], errorHandler: (Error e) {
+ commandError = e;
+ });
+
+ expect(commandError, isA<ToolExit>());
+ expect(
+ output,
+ containsAllInOrder(<Matcher>[
+ contains('Code block at line 3 is missing a language identifier.'),
+ contains('Missing language identifier for code block'),
+ ]),
+ );
+ });
+
+ test('allows unknown info strings', () async {
+ final Directory packageDir = createFakePackage('a_package', packagesDir);
+
+ packageDir.childFile('README.md').writeAsStringSync('''
+Example:
+
+```someunknowninfotag
+A B C
+```
+''');
+
+ final List<String> output = await runCapturingPrint(runner, <String>[
+ 'readme-check',
+ ]);
+
+ expect(
+ output,
+ containsAll(<Matcher>[
+ contains('Running for a_package...'),
+ contains('No issues found!'),
+ ]),
+ );
+ });
+
+ test('allows space around info strings', () async {
+ final Directory packageDir = createFakePackage('a_package', packagesDir);
+
+ packageDir.childFile('README.md').writeAsStringSync('''
+Example:
+
+``` dart
+A B C
+```
+''');
+
+ final List<String> output = await runCapturingPrint(runner, <String>[
+ 'readme-check',
+ ]);
+
+ expect(
+ output,
+ containsAll(<Matcher>[
+ contains('Running for a_package...'),
+ contains('No issues found!'),
+ ]),
+ );
+ });
+
+ test('passes when excerpt requirement is met', () async {
+ final Directory packageDir = createFakePackage('a_package', packagesDir);
+
+ packageDir.childFile('README.md').writeAsStringSync('''
+Example:
+
+<?code-excerpt "main.dart (SomeSection)"?>
+```dart
+A B C
+```
+''');
+
+ final List<String> output = await runCapturingPrint(
+ runner, <String>['readme-check', '--require-excerpts']);
+
+ expect(
+ output,
+ containsAll(<Matcher>[
+ contains('Running for a_package...'),
+ contains('No issues found!'),
+ ]),
+ );
+ });
+
+ test('fails on missing excerpt tag when requested', () async {
+ final Directory packageDir = createFakePackage('a_package', packagesDir);
+
+ packageDir.childFile('README.md').writeAsStringSync('''
+Example:
+
+```dart
+A B C
+```
+''');
+
+ Error? commandError;
+ final List<String> output = await runCapturingPrint(
+ runner, <String>['readme-check', '--require-excerpts'],
+ errorHandler: (Error e) {
+ commandError = e;
+ });
+
+ expect(commandError, isA<ToolExit>());
+ expect(
+ output,
+ containsAllInOrder(<Matcher>[
+ contains('Dart code block at line 3 is not managed by code-excerpt.'),
+ contains('Missing code-excerpt management for code block'),
+ ]),
+ );
+ });
+ });
}