blob: 12e14047161216bb8dd25ecb160e85e47531ec88 [file]
// 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.
/// Used to model the desired generation output.
///
/// See [Workspace].
library;
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:path/path.dart' as p;
import 'package:yaml/yaml.dart' as yaml;
import 'api.dart';
import 'src/html.dart';
import 'src/markdown.dart';
import 'src/utils.dart';
typedef FileContentGenerator =
Future<GenerationResults> Function(
Workspace workspace,
WorkspaceFile thisFile,
);
class GenerationResults {
final String contents;
final Outline? outline;
GenerationResults(this.contents, [this.outline]);
}
Future<GenerationResults> emptyContentGenerator(
Workspace workspace,
WorkspaceFile thisFile,
) {
return Future.value(GenerationResults(''));
}
FileContentGenerator createMarkdownGenerator(File markdownFile) {
return (Workspace workspace, WorkspaceFile thisFile) async {
var content = markdownFile.readAsStringSync();
var results = convertMarkdownWithOutline(content);
return GenerationResults(results.html, results.outline);
};
}
abstract class WorkspaceEntity {
final WorkspaceEntity? parent;
final String name;
WorkspaceEntity(this.parent, this.name);
WorkspaceFile? get mainFile;
Workspace get workspace => parent!.workspace;
WorkspaceDirectory? get parentPackage => parent?.parentPackage;
Iterable<WorkspaceEntity> get breadcrumbs {
var result = <WorkspaceEntity>[];
WorkspaceEntity? item = this;
while (item != null) {
if (item is WorkspaceFile) {
result.add(item);
} else if (item is WorkspaceDirectory) {
if (item.mainFile != null && !result.contains(item.mainFile)) {
result.add(item.mainFile!);
}
}
item = item.parent;
}
return result.reversed;
}
@override
String toString() => name;
}
class WorkspaceFile extends WorkspaceEntity {
final String path;
final FileContentGenerator contentGenerator;
final FileType fileType;
/// If set, the script to specify in an import.
String? importScript;
WorkspaceFile(
super.parent,
super.name,
this.path,
this.contentGenerator, [
this.fileType = FileType.dart,
]);
bool get isMarkdown => fileType == FileType.markdown;
@override
WorkspaceFile? get mainFile => this;
Future<GenerationResults> generatePageContents() {
return contentGenerator(workspace, this);
}
@override
String toString() => 'DocFile $name';
}
enum FileType { markdown, dart }
class WorkspaceSeparator extends WorkspaceEntity {
WorkspaceSeparator(super.parent, super.name);
@override
WorkspaceFile? get mainFile => null;
}
class WorkspaceDirectory extends WorkspaceEntity {
// todo: is isGroup used? an important distinction?
final bool isGroup;
final bool isPackage;
final List<WorkspaceEntity> children = [];
String? version;
String? description;
@override
WorkspaceFile? mainFile;
WorkspaceDirectory(
super.parent,
super.name, {
this.isGroup = false,
this.isPackage = false,
}) {
// TODO: have a default mainfile?
}
int get itemCount {
var count = mainFile == null ? 0 : 1;
for (var child in children) {
if (child is WorkspaceDirectory) {
count += child.itemCount;
} else {
count++;
}
}
return count;
}
@override
WorkspaceDirectory? get parentPackage =>
isPackage ? this : parent?.parentPackage;
T addChild<T extends WorkspaceEntity>(T entity) {
children.add(entity);
return entity;
}
WorkspaceEntity? getChild(String name) {
return children.firstWhereOrNull((c) => c.name == name);
}
@override
String toString() => 'DocContainer $name';
bool hasChild(WorkspaceFile page) {
if (mainFile == page) return true;
if (children.contains(page)) return true;
return false;
}
}
/// A model of the generation output.
class Workspace extends WorkspaceDirectory {
final Api api = Api();
final HtmlTemplate htmlTemplate;
final List<WorkspaceFile> navFiles = [];
String? footer;
Workspace(String name, {super.isPackage, required this.htmlTemplate})
: super(null, name) {
// Placeholder for the main file.
mainFile = WorkspaceFile(
this,
'index.html',
'index.html',
emptyContentGenerator,
FileType.markdown,
);
}
@override
Workspace get workspace => this;
final Map<String, Map<String, String>> _pathToCache = {};
String pathTo(WorkspaceFile target, {WorkspaceFile? from}) {
if (from == null) return target.path;
var to = target.path;
var fromDir = p.dirname(from.path);
var fromCache = _pathToCache.putIfAbsent(fromDir, () => {});
return fromCache.putIfAbsent(to, () {
// We use our own custom 'relative' implementation as the package:path one
// obsessively call's Directory.current, and would contribute as much as
// 60% of our run time.
return to.pathRelative(fromDir: fromDir);
// return p.relative(target.path, from: p.dirname(from.path));
});
}
Future<String> generateWorkspacePage(
WorkspaceFile file,
GenerationResults page,
) async {
// navbar
var navbarContent = [mainFile!, ...navFiles]
.map((target) {
var active = '';
if (navFiles.contains(file) && target == file) {
active = ' navbar__link--active';
} else if (!navFiles.contains(file) && target == mainFile) {
active = ' navbar__link--active';
}
var href = 'href="${pathTo(target, from: file)}"';
var name = target == mainFile ? 'Docs' : target.name;
return '<a $href class="navbar__item navbar__link$active" data-jot>$name</a>';
})
.join(' ');
// breadcrumbs
var breadcrumbs = file.breadcrumbs;
if (breadcrumbs.length == 1) {
breadcrumbs = [];
}
var breadcrumbsContent = breadcrumbs
.map((entity) {
var target = entity is WorkspaceFile
? entity
: (entity as WorkspaceDirectory).mainFile!;
var href = 'href="${pathTo(target, from: file)}"';
if (workspace.mainFile == target) {
return '''
<li class="breadcrumbs__item">
<a class="breadcrumbs__link" $href>
<svg viewBox="0 0 24 24" class="breadcrumbHomeIcon">
<path
d="M10 19v-5h4v5c0 .55.45 1 1 1h3c.55 0 1-.45 1-1v-7h1.7c.46 0 .68-.57.33-.87L12.67 3.6c-.38-.34-.96-.34-1.34 0l-8.36 7.53c-.34.3-.13.87.33.87H5v7c0 .55.45 1 1 1h3c.55 0 1-.45 1-1z"
fill="currentColor">
</path>
</svg>
</a>
</li>''';
} else if (file == target) {
return '<li class="breadcrumbs__item breadcrumbs__item--active">'
'<span class="breadcrumbs__link">${entity.name}</span></li>';
} else {
return '<li class="breadcrumbs__item">'
'<a $href class="breadcrumbs__link">${entity.name}</a></li>';
}
})
.join(' ');
var pathPrefix = p
.split(file.path)
.skip(1)
.map((e) => '..')
.join(p.separator);
if (pathPrefix.isNotEmpty) pathPrefix = '$pathPrefix/';
return htmlTemplate.templateSubtitute(
pageTitle: name,
pathPrefix: pathPrefix,
pageRef: file.path,
navbar: navbarContent,
breadcrumbs: breadcrumbsContent,
pageContent: page.contents.trimRight(),
toc: page.outline?.asHtml ?? '',
footer: footer ?? '',
);
}
String generateNavData() {
const encoder = JsonEncoder.withIndent('');
var navItems = [mainFile!, ...children].map(_generateNavData).toList();
return encoder.convert(navItems);
}
Map<String, dynamic> _generateNavData(WorkspaceEntity page) {
if (page is WorkspaceFile) {
return {'n': page.name, 'h': page.path};
} else if (page is WorkspaceSeparator) {
return {'n': page.name, 't': 'separator'};
} else if (page is WorkspaceDirectory) {
final mainFile = page.mainFile!;
return {
'n': mainFile.name,
'h': mainFile.path,
'c': page.children.map(_generateNavData).toList(),
};
} else {
throw StateError('unexpected subclass');
}
}
@override
String toString() => 'DocWorkspace $name';
static Workspace fromPackage(HtmlTemplate htmlTemplate, Directory dir) {
var pubspec =
yaml.loadYaml(File(p.join(dir.path, 'pubspec.yaml')).readAsStringSync())
as yaml.YamlMap;
final packageName = pubspec['name'] as String?;
final packageVersion = pubspec['version'] as String?;
var workspace = Workspace(
'package:$packageName',
htmlTemplate: htmlTemplate,
isPackage: true,
);
workspace.version = packageVersion;
if (packageVersion != null) {
workspace.footer = 'package:$packageName v$packageVersion';
}
workspace.description = pubspec['description'] as String?;
for (var file in dir.listSyncSorted().whereType<File>().where(
(f) => f.publicMarkdownFile,
)) {
var name = p.relative(file.path, from: dir.path);
var title = titleCase(
p.basenameWithoutExtension(file.path).toLowerCase(),
);
var path = '${p.withoutExtension(name)}.html';
if (name == 'README.md') {
workspace.mainFile = WorkspaceFile(
workspace,
title,
'index.html',
createMarkdownGenerator(file),
FileType.markdown,
);
} else if (name == 'CHANGELOG.md' || name == 'LICENSE.md') {
workspace.navFiles.add(
WorkspaceFile(
workspace,
title,
path,
createMarkdownGenerator(file),
FileType.markdown,
),
);
} else {
workspace.addChild(
WorkspaceFile(
workspace,
title,
path,
createMarkdownGenerator(file),
FileType.markdown,
),
);
}
}
var docDir = Directory(p.join(dir.path, 'doc'));
if (docDir.existsSync()) {
for (var file in docDir.listSyncSorted().whereType<File>().where(
(f) => f.publicMarkdownFile,
)) {
var name = file.name;
var title = titleCase(
p.basenameWithoutExtension(file.path).toLowerCase(),
);
var path = '${p.withoutExtension(name)}.html';
workspace.addChild(
WorkspaceFile(
workspace,
title,
path,
createMarkdownGenerator(file),
FileType.markdown,
),
);
}
}
return workspace;
}
WorkspaceFile? getForPath(String path) {
for (var file in navFiles) {
if (file.path == path) return file;
}
WorkspaceFile? check(WorkspaceDirectory container, String path) {
if (container.mainFile?.path == path) return container.mainFile;
for (var child in container.children) {
if (child is WorkspaceFile) {
if (child.path == path) return child;
} else {
var result = check(child as WorkspaceDirectory, path);
if (result != null) return result;
}
}
return null;
}
return check(this, path);
}
}