From 3558139da74314fcfbf7f73bb277849989bebb7f Mon Sep 17 00:00:00 2001 From: Xiaoyu Zhang Date: Fri, 5 Jun 2026 17:51:06 +0800 Subject: [PATCH] feat: add markdown generation logic to pipeline --- .github/scripts/customise-md-docs.dart | 529 +++++++++++++++++++++ .github/scripts/generate_docs_vitepress.sh | 78 +++ .github/workflows/deploy-docs.yml | 74 +++ dartdoc_options.yaml | 4 + 4 files changed, 685 insertions(+) create mode 100644 .github/scripts/customise-md-docs.dart create mode 100755 .github/scripts/generate_docs_vitepress.sh create mode 100644 .github/workflows/deploy-docs.yml create mode 100644 dartdoc_options.yaml diff --git a/.github/scripts/customise-md-docs.dart b/.github/scripts/customise-md-docs.dart new file mode 100644 index 0000000..aec75af --- /dev/null +++ b/.github/scripts/customise-md-docs.dart @@ -0,0 +1,529 @@ +import 'dart:io'; + +/// Converts VitePress-flavored API markdown into portable Markdown. +void main(List args) { + if (args.length < 2) { + stderr.writeln( + 'Usage: dart run .github/scripts/customise-md-docs.dart ', + ); + exit(64); + } + + final inputDir = Directory(args[0]); + final outputDir = Directory(args[1]); + + if (!inputDir.existsSync()) { + stderr.writeln('Input directory not found: ${inputDir.path}'); + exit(1); + } + + var fileCount = 0; + for (final entity in inputDir.listSync(recursive: true, followLinks: false)) { + if (entity is! File || !entity.path.endsWith('.md')) { + continue; + } + + final relativePath = entity.path.substring(inputDir.path.length + 1); + final targetFile = File('${outputDir.path}/$relativePath'); + targetFile.parent.createSync(recursive: true); + targetFile.writeAsStringSync( + sanitize(entity.readAsStringSync(), relativePath: relativePath), + ); + fileCount++; + } + + stdout.writeln('Sanitized $fileCount markdown files into ${outputDir.path}'); +} + +String sanitize(String input, {required String relativePath}) { + var text = input; + + text = _removeFrontmatter(text); + text = _replaceMemberSignatures(text); + text = _replaceBadges(text); + text = _removeHeadingAnchors(text); + text = _convertVitePressContainers(text); + text = _rewriteApiLinks(text, relativePath); + text = _stripRemainingHtml(text); + text = _unwrapDetailsBlocks(text); + text = _normalizeClassPreamble(text); + text = _processMemberBlocks(text); + text = _normalizeBlankLines(text); + + return text; +} + +String _removeFrontmatter(String text) { + if (!text.startsWith('---')) { + return text; + } + + final end = text.indexOf('\n---', 3); + if (end == -1) { + return text; + } + + return text.substring(end + 4).replaceFirst(RegExp(r'^\s*'), ''); +} + +String _convertVitePressContainers(String text) { + return text.replaceAllMapped( + RegExp( + r':::(info|tip|warning|danger|note|caution)\s+([^\n]+)\n([\s\S]*?)\n:::', + multiLine: true, + ), + (match) { + final title = match.group(2)!.trim(); + final content = match.group(3)!.trim(); + return '**$title**\n\n$content'; + }, + ); +} + +String _unwrapDetailsBlocks(String text) { + return text.replaceAllMapped( + RegExp( + r':::details Implementation\s*\n(```dart[\s\S]*?```)\s*\n:::', + multiLine: true, + ), + (match) => + '\n
\nImplementation\n\n${match.group(1)!}\n\n
\n', + ); +} + +String _normalizeClassPreamble(String text) { + final lines = text.split('\n'); + final firstSectionIndex = lines.indexWhere(_isSectionHeading); + if (firstSectionIndex <= 0 || !lines.first.startsWith('# ')) { + return text; + } + + final preamble = List.from(lines.sublist(0, firstSectionIndex)); + final rest = lines.sublist(firstSectionIndex); + + final badges = _badgesFromHeading(preamble.first); + final className = _classNameFromHeading(preamble.first); + preamble[0] = '# $className'; + + if (badges.isNotEmpty) { + while (preamble.isNotEmpty && preamble.last.trim().isEmpty) { + preamble.removeLast(); + } + preamble + ..add('') + ..add(badges) + ..add(''); + } + + return [...preamble, ...rest].join('\n'); +} + +String _replaceMemberSignatures(String text) { + return text.replaceAllMapped( + RegExp( + r'
]*>([\s\S]*?)
', + multiLine: true, + ), + (match) { + final signature = _htmlToPlainDart(match.group(1)!); + if (signature.trim().isEmpty) { + return ''; + } + return '\n```dart\n$signature\n```\n'; + }, + ); +} + +String _htmlToPlainDart(String html) { + var text = html; + + text = text.replaceAllMapped( + RegExp(r']*class="type-link"[^>]*>([\s\S]*?)'), + (match) => match.group(1)!.trim(), + ); + text = text.replaceAllMapped( + RegExp(r']*>([\s\S]*?)'), + (match) => match.group(1)!.trim(), + ); + + text = text.replaceAll(RegExp(r'<(pre|code)[^>]*>'), ''); + text = text.replaceAll('', '\n'); + text = text.replaceAll('', ''); + text = text.replaceAll(RegExp(r''), '\n'); + text = text.replaceAll(RegExp(r']*>'), '\n'); + text = text.replaceAll(RegExp(r']*>'), ''); + text = text.replaceAll('', ''); + + text = text + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + .replaceAll('"', '"'); + + final lines = text + .split('\n') + .map((line) => line.replaceAll(RegExp(r'[ \t]+'), ' ').trimRight()) + .where((line) => line.trim().isNotEmpty) + .toList(); + + return lines.join('\n').trim(); +} + +String _replaceBadges(String text) { + var result = text.replaceAllMapped( + RegExp(r']*text="([^"]*)"[^>]*/>'), + (match) => ' *(${match.group(1)!})*', + ); + + result = result.replaceAllMapped( + RegExp(r'([^<]*)'), + (match) => ' *(${match.group(1)!})*', + ); + + return result; +} + +String _removeHeadingAnchors(String text) { + return text.replaceAll(RegExp(r' \{#([^}]*)\}'), ''); +} + +String _rewriteApiLinks(String text, String relativePath) { + return text.replaceAllMapped( + RegExp(r'\]\((/api/[^)]+)\)'), + (match) { + final apiPath = match.group(1)!.split('#').first; + var target = apiPath.substring('/api/'.length); + if (target.endsWith('/')) { + target = '${target}index.md'; + } else { + target = '$target.md'; + } + return '](${_relativeLink(relativePath, target)})'; + }, + ); +} + +String _relativeLink(String fromFile, String toFile) { + final fromDir = _dirname(fromFile); + final fromSegments = fromDir.isEmpty ? [] : fromDir.split('/'); + final toSegments = toFile.split('/'); + + var shared = 0; + while (shared < fromSegments.length && + shared < toSegments.length && + fromSegments[shared] == toSegments[shared]) { + shared++; + } + + final ups = fromSegments.length - shared; + final remainder = toSegments.sublist(shared); + if (ups == 0) { + return remainder.join('/'); + } + + return [...List.filled(ups, '..'), ...remainder].join('/'); +} + +String _dirname(String filePath) { + final separator = filePath.lastIndexOf('/'); + return separator == -1 ? '' : filePath.substring(0, separator); +} + +String _stripRemainingHtml(String text) { + final codeBlockPattern = RegExp(r'```[\s\S]*?```', multiLine: true); + final buffer = StringBuffer(); + var lastEnd = 0; + + for (final match in codeBlockPattern.allMatches(text)) { + if (match.start > lastEnd) { + buffer.write(_stripHtmlOutsideCode(text.substring(lastEnd, match.start))); + } + buffer.write(match.group(0)); + lastEnd = match.end; + } + + if (lastEnd < text.length) { + buffer.write(_stripHtmlOutsideCode(text.substring(lastEnd))); + } + + return buffer.toString(); +} + +String _stripHtmlOutsideCode(String text) { + var result = text; + + result = result.replaceAll(RegExp(r']*>'), ''); + result = result.replaceAll('

', '\n\n'); + result = result.replaceAllMapped( + RegExp(r']*src="([^"]+)"[^>]*>'), + (match) => '![](${match.group(1)!})', + ); + result = result.replaceAll(RegExp(r'<[^>]+>'), ''); + + return result; +} + +const _objectMemberDescriptions = { + 'hashCode': 'The hash code for this object.', + 'runtimeType': 'A representation of the runtime type of the object.', + 'toString()': 'A string representation of this object.', + 'noSuchMethod()': 'Invoked when a nonexistent method or property is accessed.', + 'operator ==()': 'The equality operator.', +}; + +String _processMemberBlocks(String text) { + final lines = text.split('\n'); + final result = []; + var index = 0; + + while (index < lines.length) { + final line = lines[index]; + if (_isSectionHeading(line)) { + final sectionName = line.replaceFirst('## ', '').trim(); + index++; + final sectionLines = []; + final memberBlocks = >[]; + var currentMember = []; + var inMember = false; + + while (index < lines.length && !_isSectionHeading(lines[index])) { + if (lines[index].startsWith('### ')) { + if (inMember) { + memberBlocks.add(currentMember); + } + currentMember = [lines[index]]; + inMember = true; + } else if (inMember) { + currentMember.add(lines[index]); + } else { + sectionLines.add(lines[index]); + } + index++; + } + + if (inMember) { + memberBlocks.add(currentMember); + } + + final hasSectionContent = sectionLines.any((l) => l.trim().isNotEmpty) || + memberBlocks.isNotEmpty; + + if (hasSectionContent) { + result.add(line); + result.addAll(sectionLines); + for (final member in memberBlocks) { + result.addAll(_processMemberBlock(member, sectionName)); + } + } + continue; + } + + result.add(line); + index++; + } + + return result.join('\n'); +} + +List _processMemberBlock(List block, String sectionName) { + if (_isInheritedMember(block)) { + return _compactInheritedMemberBlock(block); + } + + if (_isPropertiesSection(sectionName)) { + return _compactPropertyMemberBlock(block); + } + + return _processMethodMemberBlock(block); +} + +bool _isPropertiesSection(String sectionName) { + final normalized = sectionName.toLowerCase(); + return normalized == 'properties' || normalized == 'constants'; +} + +List _compactInheritedMemberBlock(List block) { + final heading = block.first; + final name = _memberNameFromHeading(heading); + final badges = _badgesFromHeading(heading); + final signature = _extractSignatureFromBlock(block); + final description = + _extractShortDescription(block) ?? _objectMemberDescriptions[name] ?? ''; + + return _compactMemberLines( + name: name, + signature: signature, + description: description, + badges: badges, + ); +} + +List _compactPropertyMemberBlock(List block) { + final heading = block.first; + final name = _memberNameFromHeading(heading); + final badges = _badgesFromHeading(heading); + final signature = _extractSignatureFromBlock(block); + final description = _extractShortDescription(block); + + return _compactMemberLines( + name: name, + signature: signature, + description: description ?? '', + badges: badges, + ); +} + +List _compactMemberLines({ + required String name, + required String signature, + required String description, + required String badges, +}) { + return [ + '### $name', + '', + if (signature.isNotEmpty) '`$signature`', + if (signature.isNotEmpty) '', + if (description.isNotEmpty) description, + if (description.isNotEmpty) '', + if (badges.isNotEmpty) badges, + if (badges.isNotEmpty) '', + ]; +} + +List _processMethodMemberBlock(List block) { + final heading = block.first; + final body = _stripTrivialDetails(block.skip(1).join('\n')); + return [ + heading, + if (body.trim().isNotEmpty) body, + ]; +} + +String _stripTrivialDetails(String text) { + return text.replaceAllMapped( + RegExp( + r'
\s*Implementation\s*\n```dart\s*\n([\s\S]*?)```\s*\n
', + multiLine: true, + ), + (match) { + if (_isTrivialImplementation(match.group(1)!)) { + return ''; + } + return match.group(0)!; + }, + ); +} + +bool _isTrivialImplementation(String impl) { + final normalized = impl.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (normalized.contains('=>') || normalized.contains('{')) { + return false; + } + + if (RegExp(r'^[\w<>,\?\[\]\s\.]+\s+\w+;$').hasMatch(normalized)) { + return true; + } + + return RegExp(r'^[\w<>,\?\[\]\s\.]+\s+\w+\([^)]*\);$').hasMatch(normalized); +} + +String _badgesFromHeading(String heading) { + final markdownBadges = RegExp(r'\*\([^)]*\)\*') + .allMatches(heading) + .map((match) => match.group(0)!) + .join(' '); + + if (markdownBadges.isNotEmpty) { + return markdownBadges; + } + + return RegExp(r']*text="([^"]*)"[^>]*/>') + .allMatches(heading) + .map((match) => '*(${match.group(1)!})*') + .join(' '); +} + +String _classNameFromHeading(String heading) { + var name = heading.replaceFirst(RegExp(r'^#\s+'), '').trim(); + name = name.replaceAll(RegExp(r'\s*\*\([^)]*\)\*'), '').trim(); + return name; +} + +String _memberNameFromHeading(String heading) { + var name = heading.replaceFirst(RegExp(r'^###\s+'), '').trim(); + name = name.replaceAll(RegExp(r'\s*\*\([^)]*\)\*'), '').trim(); + return name; +} + +String _extractSignatureFromBlock(List block) { + final body = block.skip(1).join('\n'); + final match = + RegExp(r'```dart\s*\n([\s\S]*?)```', multiLine: true).firstMatch(body); + if (match == null) { + final inline = RegExp(r'`([^`]+)`').firstMatch(body); + return inline?.group(1)?.trim() ?? ''; + } + + return match + .group(1)! + .split('\n') + .map((line) => line.trim()) + .where((line) => line.isNotEmpty) + .join(' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); +} + +String? _extractShortDescription(List block) { + var body = block.skip(1).join('\n'); + body = body.replaceAll(RegExp(r'```[\s\S]*?```', multiLine: true), ''); + body = body.replaceAll(RegExp(r'
[\s\S]*?
', multiLine: true), ''); + body = body.replaceAll('**Implementation**', ''); + body = body.replaceAll('*Inherited from Object.*', ''); + body = body.replaceAll(RegExp(r'\*\([^)]*\)\*'), ''); + + for (final paragraph in body.split(RegExp(r'\n\s*\n'))) { + final trimmed = paragraph.trim(); + if (trimmed.isEmpty || trimmed.startsWith('#')) { + continue; + } + return _firstSentence(trimmed); + } + + return null; +} + +String _firstSentence(String text) { + final normalized = text.replaceAll(RegExp(r'\s+'), ' '); + final match = RegExp(r'^.*?[.!?](?:\s|$)').firstMatch(normalized); + if (match != null) { + return match.group(0)!.trim(); + } + return normalized.trim(); +} + +bool _isSectionHeading(String line) => + line.startsWith('## ') && !line.startsWith('### '); + +bool _isInheritedMember(List block) { + if (block.isEmpty) { + return false; + } + + final heading = block.first; + if (heading.contains('*(inherited)*') || + RegExp(r']*text="inherited"').hasMatch(heading)) { + return true; + } + + final body = block.skip(1).join('\n'); + return body.contains('Inherited from Object'); +} + +String _normalizeBlankLines(String text) { + return text + .replaceAll(RegExp(r'\n{3,}'), '\n\n') + .trim() + .replaceAll(RegExp(r'\n+$'), '\n'); +} diff --git a/.github/scripts/generate_docs_vitepress.sh b/.github/scripts/generate_docs_vitepress.sh new file mode 100755 index 0000000..ca79754 --- /dev/null +++ b/.github/scripts/generate_docs_vitepress.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Generate sanitized API Markdown for the md-doc branch (md-docs/). +# Raw vitepress output is written to build/docs/vitepress-raw/ (gitignored). + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +RAW_OUTPUT_DIR="build/docs/vitepress-raw" +RAW_API_DIR="${RAW_OUTPUT_DIR}/api" +OUTPUT_DIR="md-docs" +SAMPLE_FILE="${OUTPUT_DIR}/airwallex/Airwallex.md" +LOG="$(mktemp)" + +cleanup() { + rm -f "$LOG" +} +trap cleanup EXIT + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +require_path() { + local path="$1" + local label="$2" + if [ ! -e "$path" ]; then + fail "${label} not found at ${path}." + fi +} + +echo "== API reference markdown ==" +echo "Output: ${OUTPUT_DIR}/" +echo + +flutter pub get + +echo "Installing dartdoc_vitepress 1.2.1..." +dart pub global activate dartdoc_vitepress 1.2.1 + +# dartdoc_vitepress 1.2.1 is missing lib/resources/vitepress/.gitignore in the +# published package, which aborts generation before api/ is written. +VITEPRESS_PKG="$(find "${HOME}/.pub-cache/hosted/pub.dev" -maxdepth 1 -name 'dartdoc_vitepress-*' | sort -V | tail -1)" +if [ -n "${VITEPRESS_PKG}" ]; then + mkdir -p "${VITEPRESS_PKG}/lib/resources/vitepress" + touch "${VITEPRESS_PKG}/lib/resources/vitepress/.gitignore" +else + fail "Could not locate dartdoc_vitepress in pub cache." +fi + +rm -rf "${RAW_OUTPUT_DIR}" "${OUTPUT_DIR}" + +echo "Generating Markdown..." +if ! dart pub global run dartdoc_vitepress \ + --format vitepress \ + --output "${RAW_OUTPUT_DIR}" 2>&1 | tee "$LOG"; then + fail "dartdoc_vitepress exited with an error. See output above." +fi + +if grep -Eiq 'failed:|PathNotFoundException|Unhandled exception' "$LOG"; then + fail "dartdoc_vitepress reported errors in log. See output above." +fi + +require_path "$RAW_API_DIR" "Raw API output directory" + +echo "Sanitizing Markdown..." +if ! dart run .github/scripts/customise-md-docs.dart "${RAW_API_DIR}" "${OUTPUT_DIR}"; then + fail "Markdown sanitization failed." +fi + +require_path "$SAMPLE_FILE" "Sanitized sample API page" + +echo +echo "Done." +echo " Output: ${OUTPUT_DIR}/" +echo " Sample: ${SAMPLE_FILE}" diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..57c6f7e --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,74 @@ +name: Deploy Documentation + +on: + release: + types: [published] + workflow_dispatch: + +concurrency: + group: deploy-docs + cancel-in-progress: false + +permissions: + contents: write + +jobs: + generate-docs: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.ref }} + + - name: Install Flutter + uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2.19.0 + with: + flutter-version: '3.38.1' + cache: true + + - name: Generate Markdown documentation + run: | + chmod +x ./.github/scripts/generate_docs_vitepress.sh + ./.github/scripts/generate_docs_vitepress.sh + + - name: Upload markdown documentation + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: markdown-docs + path: md-docs/ + retention-days: 1 + + update-md-doc: + needs: generate-docs + runs-on: ubuntu-latest + + steps: + - name: Checkout md-doc branch + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: md-doc + + - name: Clean previous docs + run: git rm -rf . 2>/dev/null || true + + - name: Download markdown documentation + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: markdown-docs + path: md-docs + + - name: Commit and push + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git add md-docs + if git diff --staged --quiet; then + echo "No markdown changes to commit" + exit 0 + fi + + git commit -m "Deploy to md-doc from @ ${{ github.repository }}@${{ github.sha }}." + git push origin md-doc diff --git a/dartdoc_options.yaml b/dartdoc_options.yaml new file mode 100644 index 0000000..64c25f0 --- /dev/null +++ b/dartdoc_options.yaml @@ -0,0 +1,4 @@ +dartdoc: + linkToSource: + root: '.' + uriTemplate: 'https://github.com/airwallex/airwallex-payment-flutter/tree/main/%f%#L%l%'