| // Copyright 2019 The Fuchsia Authors. 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 'dart:io'; |
| |
| import 'package:path/path.dart' as path; |
| import 'package:yaml/yaml.dart'; |
| |
| import 'package:doc_checker/errors.dart'; |
| import 'package:doc_checker/link_verifier.dart'; |
| |
| /// Verify yaml files. |
| /// _toc.yaml files are verified for fuchsia.dev hosting compliance, |
| /// while other yaml files are just checked for regular yaml syntax. |
| class YamlChecker { |
| Set<String> _tocYamlSet; |
| Set<String> _otherYamlSet; |
| Set<String> _mdSet; |
| String _rootYaml; |
| String _rootDir; |
| |
| // List of errors found. |
| final List<Error> errors = <Error>[]; |
| final List<Link<String>> outOfTreeLinks = <Link<String>>[]; |
| |
| static const List<String> validStatusValues = <String>[ |
| 'alpha', |
| 'beta', |
| 'deprecated', |
| 'experimental', |
| 'external', |
| 'limited', |
| 'new' |
| ]; |
| |
| /// Creates a new instance of YamlChecker. |
| /// |
| /// [rootDir] is the parent directory of 'docs'. This is used |
| /// to locate the files on the filesystem. |
| /// [rootYaml] is the fuchsia.dev compliant yaml file to start the |
| /// checking from. |
| /// [yamls] is the list of .yaml files to check. |
| /// [mdFiles] is the list of .md files to make sure they are |
| /// referenced in the yaml files. |
| YamlChecker(String rootDir, String rootYaml, List<String> yamls, |
| List<String> mdFiles) { |
| _rootYaml = rootYaml; |
| _rootDir = rootDir; |
| |
| if (rootYaml != null && !_isFuchsiaDevYaml(rootYaml)) { |
| throw AssertionError( |
| 'If specified, root YAML file needs to be named "_toc.yaml": $rootYaml'); |
| } |
| |
| _tocYamlSet = <String>{} |
| ..addAll(yamls.where(_isFuchsiaDevYaml)) |
| ..remove(_rootYaml); |
| |
| _otherYamlSet = <String>{} |
| ..addAll(yamls.where((yaml) => !_isFuchsiaDevYaml(yaml))) |
| ..remove(_rootYaml); |
| |
| _mdSet = <String>{} |
| ..addAll(filterHidden(mdFiles)) |
| // Remove navbar.md since it is only used on fuchsia.googlesource.com. |
| ..remove('$_rootDir/docs/navbar.md') |
| // Remove docs/gen/build_arguments.md since it is generated, it is |
| // accessed as a source file and not published. |
| ..remove('$_rootDir/docs/gen/build_arguments.md'); |
| } |
| |
| static bool _isFuchsiaDevYaml(String filename) { |
| return path.basename(filename) == '_toc.yaml'; |
| } |
| |
| /// Filters out paths that are hidden names according to |
| /// https://developers.google.com/devsite/reference/filenames?hl=en#hidden_files_single_underscore. |
| /// This function expects the input paths to already be canonicalized. |
| List<String> filterHidden(List<String> mdFiles) { |
| return mdFiles |
| .where((doc) => !path |
| .split(path.relative(doc, from: _rootDir)) |
| .any((component) => component.startsWith('_'))) |
| .toList(); |
| } |
| |
| /// Checks the validity of the yaml files. |
| /// Returns true if no errors are found. |
| /// Errors can be retrieved via the [errors] |
| /// property. |
| Future<bool> check() { |
| var checks = <Future>[]; |
| |
| if (_rootYaml != null) { |
| File f = File(_rootYaml); |
| checks.add(f.readAsString().then((String data) { |
| parse(loadYamlDocuments(data), _rootYaml); |
| for (String s in _tocYamlSet) { |
| errors |
| .add(Error(ErrorType.invalidMenu, null, 'unreferenced yaml $s')); |
| } |
| for (String s in _mdSet) { |
| errors.add(Error(ErrorType.unreachablePage, null, |
| 'File $s not referenced in any yaml file')); |
| } |
| })); |
| } |
| for (String s in _otherYamlSet) { |
| File f = File(s); |
| checks.add(f.readAsString().then((String data) { |
| try { |
| loadYamlDocuments(data); |
| } on YamlException catch (e) { |
| errors.add(Error(ErrorType.unparseableYaml, null, '$s: $e')); |
| } |
| })); |
| } |
| |
| return Future.wait(checks).then((value) => errors.isEmpty); |
| } |
| |
| /// Parses the yaml structure. |
| void parse(List<YamlDocument> doc, String filename) { |
| for (YamlDocument d in doc) { |
| validateTopLevel(d.contents.value, filename); |
| } |
| } |
| |
| /// Validates the top level of the menu. |
| void validateTopLevel(YamlMap map, String filename) { |
| for (String key in map.keys) { |
| switch (key) { |
| case 'guides': |
| case 'samples': |
| case 'support': |
| case 'reference': |
| case 'toc': |
| { |
| validateTocElement(map[key], filename); |
| } |
| break; |
| default: |
| { |
| errors.add(Error(ErrorType.invalidMenu, filename, |
| 'Unknown top level key: $key')); |
| break; |
| } |
| } |
| } |
| } |
| |
| /// Validates the toc element in the menu. |
| void validateTocElement(YamlNode val, String filename) { |
| // valid entries are documented at |
| if (val is YamlList) { |
| for (YamlNode node in val) { |
| if (node.runtimeType == YamlMap) { |
| validateContentMap(node, filename); |
| } else { |
| errors.add(Error(ErrorType.invalidMenu, filename, |
| 'Unexpected node of type: ${node.runtimeType} $node')); |
| } |
| } |
| } else { |
| errors.add(Error(ErrorType.invalidMenu, filename, |
| 'Expected a list, got ${val.runtimeType}: $val')); |
| } |
| } |
| |
| /// Validates the content of toc. |
| void validateContentMap(YamlMap map, String filename) { |
| for (String key in map.keys) { |
| switch (key) { |
| case 'alternate_paths': |
| { |
| validatePathList(map[key], filename); |
| } |
| break; |
| case 'break': |
| case 'skip_translation': |
| { |
| // only include if break : true. |
| if (map[key] != true) { |
| errors.add(Error(ErrorType.invalidMenu, filename, |
| '$key should only be included when set to true')); |
| } |
| } |
| break; |
| case 'section': |
| case 'contents': |
| { |
| validateTocElement(map[key], filename); |
| } |
| break; |
| case 'include': |
| { |
| processIncludedFile(map[key], filename); |
| } |
| break; |
| case 'path': |
| { |
| String menuPath = map[key]; |
| if (validatePath(menuPath, filename)) { |
| // If the path is to a file, check that the file exists. |
| if (!menuPath.startsWith('https://') && |
| !menuPath.startsWith('//')) { |
| checkFileExists(menuPath, filename); |
| } else { |
| Uri uri; |
| try { |
| if (menuPath.startsWith('//')) { |
| uri = Uri.parse('https:$menuPath'); |
| } else { |
| uri = Uri.parse(menuPath); |
| } |
| } on FormatException { |
| errors.add(Error(ErrorType.invalidUri, filename, menuPath)); |
| continue; |
| } |
| outOfTreeLinks.add(Link(uri, filename)); |
| } |
| } |
| } |
| break; |
| case 'path_attributes': |
| { |
| errors.add(Error(ErrorType.invalidMenu, filename, |
| 'path_attributes not supported on fuchsia.dev')); |
| } |
| break; |
| case 'status': |
| { |
| if (!validStatusValues.contains(map[key])) { |
| errors.add(Error(ErrorType.invalidMenu, filename, |
| 'invalid status value of ${map[key]}')); |
| } |
| } |
| break; |
| case 'step_group': |
| { |
| // make sure there is no section in this group. |
| if (map.containsKey('section')) { |
| errors.add(Error(ErrorType.invalidMenu, filename, |
| 'invalid use of \'step_group\'. Group cannot also contain \`section\`')); |
| } |
| if (!(map[key] is String)) { |
| errors.add(Error(ErrorType.invalidMenu, filename, |
| 'invalid value of \'step_group\'. Expected String got ${map[key].runtimeType} ${map[key]}')); |
| } |
| } |
| break; |
| case 'style': |
| { |
| if (map.containsKey('break') || map.containsKey('include')) { |
| errors.add(Error(ErrorType.invalidMenu, filename, |
| 'invalid use of \'style\'. Group cannot also contain `break` nor `include`')); |
| } |
| if (map[key] != 'accordion' && map[key] != 'divider') { |
| errors.add(Error(ErrorType.invalidMenu, filename, |
| 'invalid value of `style`. Expected `accordion` or `divider`')); |
| } |
| if (!map.containsKey('heading') && !map.containsKey('section')) { |
| errors.add(Error(ErrorType.invalidMenu, filename, |
| 'invalid use of \'style\'. Group must also have `heading` or `section`.')); |
| } |
| } |
| break; |
| case 'heading': |
| case 'name': |
| case 'title': |
| { |
| if (map[key].runtimeType != String) { |
| errors.add(Error(ErrorType.invalidMenu, filename, |
| 'Expected String for $key, got ${map[key].runtimeType}')); |
| } |
| } |
| break; |
| default: |
| errors.add(Error( |
| ErrorType.invalidMenu, filename, 'Unknown Content Key $key')); |
| } |
| } |
| } |
| |
| /// Handles an included yaml file. It is validated completely then returns. |
| void processIncludedFile(String menuPath, String parentFilename) { |
| if (validatePath(menuPath, parentFilename)) { |
| String filePath = '$_rootDir$menuPath'; |
| |
| // parse in a try/catch to handle any syntax errors reading the included file. |
| try { |
| parse(loadYamlDocuments(File(filePath).readAsStringSync()), menuPath); |
| _tocYamlSet.remove(filePath); |
| } on Exception catch (exception) { |
| errors.add( |
| Error(ErrorType.invalidMenu, parentFilename, exception.toString())); |
| } |
| } |
| } |
| |
| /// Validates a list of paths are valid paths for the menu. |
| bool validatePathList(List<String> paths, String filename) { |
| for (String s in paths) { |
| if (!validatePath(s, filename)) { |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| /// Validates the path is valid for the menu. |
| /// |
| /// Valid paths are /docs/* and |
| /// http URLs. Exceptions are made for |
| /// CONTRIBUTING.md and CODE_OF_CONDUCT.md which |
| /// are in the root of the project. |
| bool validatePath(String menuPath, String filename) { |
| if (!menuPath.startsWith('/docs') && |
| menuPath != '/CONTRIBUTING.md' && |
| menuPath != '/CODE_OF_CONDUCT.md' && |
| !menuPath.startsWith('http://') && |
| !menuPath.startsWith('https://') && |
| !menuPath.startsWith('//')) { |
| errors.add(Error(ErrorType.invalidMenu, filename, |
| 'Path needs to start with \'/docs\', got $menuPath')); |
| return false; |
| } |
| |
| return true; |
| } |
| |
| /// Check that the file pointed to by the path exists. |
| /// if it does, remove the file from the mdSet to keep track of |
| /// which files are referenced. |
| void checkFileExists(String menuPath, String filename) { |
| String filePath = '$_rootDir$menuPath'; |
| |
| if (File(filePath).existsSync()) { |
| _mdSet.remove(filePath); |
| } else { |
| // could be a directory, so add README.md. |
| filePath = '$filePath/README.md'; |
| if (File(filePath).existsSync()) { |
| _mdSet.remove(filePath); |
| } else { |
| errors.add(Error(ErrorType.invalidMenu, filename, |
| 'Invalid menu path $menuPath not found')); |
| } |
| } |
| } |
| } |