使用 Gradle 自动更新文档内容 (#4403)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Glavo
2025-09-07 15:47:17 +08:00
committed by GitHub
parent 86ad055f8f
commit 5697cbd251
18 changed files with 759 additions and 20 deletions

View File

@@ -0,0 +1,151 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.gradle.docs;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/// @author Glavo
public record Document(DocumentFileTree directory,
Path file,
String name, DocumentLocale locale,
List<Item> items) {
private static final Pattern MACRO_BEGIN = Pattern.compile(
"<!-- #BEGIN (?<name>\\w+) -->"
);
private static final Pattern MACRO_PROPERTY_LINE = Pattern.compile(
"<!-- #PROPERTY (?<name>\\w+)=(?<value>.*) -->"
);
private static String parsePropertyValue(String value) {
int i = 0;
while (i < value.length()) {
char ch = value.charAt(i);
if (ch == '\\')
break;
i++;
}
if (i == value.length())
return value;
StringBuilder builder = new StringBuilder(value.length());
builder.append(value, 0, i);
for (; i < value.length(); i++) {
char ch = value.charAt(i);
if (ch == '\\' && i < value.length() - 1) {
char next = value.charAt(++i);
switch (next) {
case 'n' -> builder.append('\n');
case 'r' -> builder.append('\r');
case '\\' -> builder.append('\\');
default -> builder.append(next);
}
} else {
builder.append(ch);
}
}
return builder.toString();
}
static void writePropertyValue(StringBuilder builder, String value) {
for (int i = 0; i < value.length(); i++) {
char ch = value.charAt(i);
switch (ch) {
case '\\' -> builder.append("\\\\");
case '\r' -> builder.append("\\r");
case '\n' -> builder.append("\\n");
default -> builder.append(ch);
}
}
}
public static Document load(DocumentFileTree directory, Path file, String name, DocumentLocale locale) throws IOException {
var items = new ArrayList<Item>();
try (var reader = Files.newBufferedReader(file)) {
String line;
while ((line = reader.readLine()) != null) {
if (!line.startsWith("<!-- #")) {
items.add(new Line(line));
} else {
Matcher matcher = MACRO_BEGIN.matcher(line);
if (!matcher.matches())
throw new IOException("Invalid macro begin line: " + line);
String macroName = matcher.group("name");
String endLine = "<!-- #END " + macroName + " -->";
var properties = new LinkedHashMap<String, List<String>>();
var lines = new ArrayList<String>();
// Handle properties
while ((line = reader.readLine()) != null) {
if (!line.startsWith("<!-- #") || line.equals(endLine))
break;
Matcher propertyMatcher = MACRO_PROPERTY_LINE.matcher(line);
if (propertyMatcher.matches()) {
String propertyName = propertyMatcher.group("name");
String propertyValue = parsePropertyValue(propertyMatcher.group("value"));
properties.computeIfAbsent(propertyName, k -> new ArrayList<>(1))
.add(propertyValue);
} else {
throw new IOException("Invalid macro property line: " + line);
}
}
// Handle lines
if (line != null && !line.equals(endLine)) {
while ((line = reader.readLine()) != null) {
if (line.startsWith("<!-- #"))
break;
lines.add(line);
}
}
if (line == null || !line.equals(endLine))
throw new IOException("Invalid macro end line: " + line);
items.add(new MacroBlock(macroName,
Collections.unmodifiableMap(properties),
Collections.unmodifiableList(lines)));
}
}
}
return new Document(directory, file, name, locale, items);
}
public sealed interface Item {
}
public record MacroBlock(String name, Map<String, List<String>> properties,
List<String> contentLines) implements Item {
}
public record Line(String content) implements Item {
}
}

View File

@@ -0,0 +1,112 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.gradle.docs;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.TreeMap;
/// @author Glavo
public final class DocumentFileTree {
public static DocumentFileTree load(Path dir) throws IOException {
Path documentsDir = dir.toRealPath();
DocumentFileTree rootTree = new DocumentFileTree();
Files.walkFileTree(documentsDir, new SimpleFileVisitor<>() {
@Override
public @NotNull FileVisitResult visitFile(@NotNull Path file, @NotNull BasicFileAttributes attrs) throws IOException {
String fileName = file.getFileName().toString();
if (fileName.endsWith(".md")) {
DocumentFileTree tree = rootTree.getFileTree(documentsDir.relativize(file.getParent()));
if (tree == null)
throw new AssertionError();
var result = DocumentLocale.parseFileName(fileName.substring(0, fileName.length() - ".md".length()));
tree.getFiles().computeIfAbsent(result.name(), name -> new LocalizedDocument(tree, name))
.getDocuments()
.put(result.locale(), Document.load(tree, file, result.name(), result.locale()));
}
return FileVisitResult.CONTINUE;
}
});
return rootTree;
}
private final @Nullable DocumentFileTree parent;
private final TreeMap<String, DocumentFileTree> children = new TreeMap<>();
private final TreeMap<String, LocalizedDocument> files = new TreeMap<>();
public DocumentFileTree() {
this(null);
}
public DocumentFileTree(@Nullable DocumentFileTree parent) {
this.parent = parent;
}
@Nullable DocumentFileTree getFileTree(Path relativePath) {
if (relativePath.isAbsolute())
throw new IllegalArgumentException(relativePath + " is absolute");
if (relativePath.getNameCount() == 0)
throw new IllegalArgumentException(relativePath + " is empty");
if (relativePath.getNameCount() == 1 && relativePath.getName(0).toString().isEmpty())
return this;
DocumentFileTree current = this;
for (int i = 0; i < relativePath.getNameCount(); i++) {
String name = relativePath.getName(i).toString();
if (name.isEmpty())
throw new IllegalStateException(name + " is empty");
else if (name.equals("."))
continue;
else if (name.equals("..")) {
current = current.parent;
if (current == null)
return null;
} else {
DocumentFileTree finalCurrent = current;
current = current.children.computeIfAbsent(name, ignored -> new DocumentFileTree(finalCurrent));
}
}
return current;
}
public @Nullable DocumentFileTree getParent() {
return parent;
}
public TreeMap<String, DocumentFileTree> getChildren() {
return children;
}
public TreeMap<String, LocalizedDocument> getFiles() {
return files;
}
}

View File

@@ -0,0 +1,118 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.gradle.docs;
import java.util.List;
import java.util.Locale;
/// @author Glavo
public enum DocumentLocale {
ENGLISH(Locale.ENGLISH, "") {
@Override
public List<DocumentLocale> getCandidates() {
return List.of(ENGLISH);
}
},
SIMPLIFIED_CHINESE(Locale.forLanguageTag("zh-Hans"), "zh"),
TRADITIONAL_CHINESE("zh-Hant") {
@Override
public List<DocumentLocale> getCandidates() {
return List.of(TRADITIONAL_CHINESE, SIMPLIFIED_CHINESE, ENGLISH);
}
},
WENYAN("lzh") {
@Override
public String getLanguageDisplayName() {
return TRADITIONAL_CHINESE.getLanguageDisplayName();
}
@Override
public String getSubLanguageDisplayName() {
return "文言";
}
@Override
public List<DocumentLocale> getCandidates() {
return List.of(WENYAN, TRADITIONAL_CHINESE, SIMPLIFIED_CHINESE, ENGLISH);
}
},
JAPANESE("ja"),
SPANISH("es"),
RUSSIAN("ru"),
UKRAINIAN("uk"),
;
public record LocaleAndName(DocumentLocale locale, String name) {
}
public static LocaleAndName parseFileName(String fileNameWithoutExtension) {
for (DocumentLocale locale : values()) {
String suffix = locale.getFileNameSuffix();
if (suffix.isEmpty())
continue;
if (fileNameWithoutExtension.endsWith(suffix))
return new LocaleAndName(locale, fileNameWithoutExtension.substring(0, fileNameWithoutExtension.length() - locale.getFileNameSuffix().length()));
}
return new LocaleAndName(ENGLISH, fileNameWithoutExtension);
}
private final Locale locale;
private final String languageTag;
private final String fileNameSuffix;
DocumentLocale(String languageTag) {
this(Locale.forLanguageTag(languageTag), languageTag);
}
DocumentLocale(Locale locale, String languageTag) {
this.locale = locale;
this.languageTag = languageTag;
this.fileNameSuffix = languageTag.isEmpty() ? "" : "_" + languageTag.replace('-', '_');
}
public String getLanguageDisplayName() {
return locale.getDisplayLanguage(locale);
}
public String getSubLanguageDisplayName() {
boolean hasScript = !locale.getScript().isEmpty();
boolean hasRegion = !locale.getCountry().isEmpty();
if (hasScript && hasRegion)
throw new AssertionError("Unsupported locale: " + locale);
if (hasScript)
return locale.getDisplayScript(locale);
if (hasRegion)
return locale.getDisplayCountry(locale);
return "";
}
public Locale getLocale() {
return locale;
}
public String getFileNameSuffix() {
return fileNameSuffix;
}
public List<DocumentLocale> getCandidates() {
return List.of(this, ENGLISH);
}
}

View File

@@ -0,0 +1,62 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.gradle.docs;
import java.util.EnumMap;
/// @author Glavo
public final class LocalizedDocument {
private final DocumentFileTree directory;
private final String name;
private final EnumMap<DocumentLocale, Document> documents = new EnumMap<>(DocumentLocale.class);
public LocalizedDocument(DocumentFileTree directory, String name) {
this.directory = directory;
this.name = name;
}
public String getName() {
return name;
}
public DocumentFileTree getDirectory() {
return directory;
}
public EnumMap<DocumentLocale, Document> getDocuments() {
return documents;
}
@Override
public boolean equals(Object obj) {
return obj instanceof LocalizedDocument that
&& this.documents.equals(that.documents);
}
@Override
public int hashCode() {
return documents.hashCode();
}
@Override
public String toString() {
return "LocalizedDocument[" +
"files=" + documents + ']';
}
}

View File

@@ -0,0 +1,144 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.gradle.docs;
import javax.print.Doc;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
/// @author Glavo
public enum MacroProcessor {
LANGUAGE_SWITCHER {
private static <T> boolean containsIdentity(List<T> list, T element) {
for (T t : list) {
if (t == element)
return true;
}
return false;
}
@Override
public void apply(Document document,
Document.MacroBlock macroBlock,
StringBuilder outputBuilder) throws IOException {
LocalizedDocument localized = document.directory().getFiles().get(document.name());
if (localized == null || localized.getDocuments().isEmpty())
throw new AssertionError("Document " + document.name() + " does not exist");
MacroProcessor.writeBegin(outputBuilder, macroBlock);
if (localized.getDocuments().size() > 1) {
var languageToDocs = new LinkedHashMap<String, List<Document>>();
for (DocumentLocale locale : DocumentLocale.values()) {
Document targetDoc = localized.getDocuments().get(locale);
if (targetDoc != null) {
languageToDocs.computeIfAbsent(locale.getLanguageDisplayName(), name -> new ArrayList<>(1))
.add(targetDoc);
}
}
boolean firstLanguage = true;
for (var entry : languageToDocs.entrySet()) {
if (firstLanguage)
firstLanguage = false;
else
outputBuilder.append(" | ");
String languageName = entry.getKey();
List<Document> targetDocs = entry.getValue();
boolean containsCurrent = containsIdentity(targetDocs, document);
if (targetDocs.size() == 1) {
if (containsCurrent)
outputBuilder.append("**").append(languageName).append("**");
else
outputBuilder.append("[").append(languageName).append("](").append(targetDocs.get(0).file().getFileName()).append(")");
} else {
if (containsCurrent)
outputBuilder.append("**").append(languageName).append("**");
else
outputBuilder.append(languageName);
outputBuilder.append(" (");
boolean isFirst = true;
for (Document targetDoc : targetDocs) {
if (isFirst)
isFirst = false;
else
outputBuilder.append(", ");
String subLanguage = targetDoc.locale().getSubLanguageDisplayName();
if (targetDoc == document) {
outputBuilder.append("**").append(subLanguage).append("**");
} else {
outputBuilder.append('[').append(subLanguage).append("](").append(targetDoc.file().getFileName()).append(")");
}
}
outputBuilder.append(")");
}
}
outputBuilder.append('\n');
}
MacroProcessor.writeEnd(outputBuilder, macroBlock);
}
},
BLOCK {
@Override
public void apply(Document document, Document.MacroBlock macroBlock, StringBuilder outputBuilder) throws IOException {
MacroProcessor.writeBegin(outputBuilder, macroBlock);
MacroProcessor.writeProperties(outputBuilder, macroBlock);
for (String line : macroBlock.contentLines()) {
outputBuilder.append(line).append('\n');
}
MacroProcessor.writeEnd(outputBuilder, macroBlock);
}
};
private static void writeBegin(StringBuilder builder, Document.MacroBlock macroBlock) throws IOException {
builder.append("<!-- #BEGIN ");
builder.append(macroBlock.name());
builder.append(" -->\n");
}
private static void writeEnd(StringBuilder builder, Document.MacroBlock macroBlock) throws IOException {
builder.append("<!-- #END ");
builder.append(macroBlock.name());
builder.append(" -->\n");
}
private static void writeProperties(StringBuilder builder, Document.MacroBlock macroBlock) throws IOException {
macroBlock.properties().forEach((key, values) -> {
for (String value : values) {
builder.append("<!-- #PROPERTY ").append(key).append('=');
Document.writePropertyValue(builder, value);
builder.append(" -->\n");
}
});
}
public abstract void apply(Document document,
Document.MacroBlock macroBlock,
StringBuilder outputBuilder) throws IOException;
}

View File

@@ -0,0 +1,131 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.gradle.docs;
import org.gradle.api.DefaultTask;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.tasks.InputDirectory;
import org.gradle.api.tasks.TaskAction;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Pattern;
/// @author Glavo
public abstract class UpdateDocuments extends DefaultTask {
@InputDirectory
public abstract DirectoryProperty getDocumentsDir();
// ---
private static final Pattern LINK_PATTERN = Pattern.compile(
"(?<=]\\()[a-zA-Z0-9_\\-./]+\\.md(?=\\))"
);
private void processLine(StringBuilder outputBuilder, String line, Document document) {
outputBuilder.append(LINK_PATTERN.matcher(line).replaceAll(matchResult -> {
String rawLink = matchResult.group();
String[] splitPath = rawLink.split("/");
if (splitPath.length == 0)
return rawLink;
String fileName = splitPath[splitPath.length - 1];
if (!fileName.endsWith(".md"))
return rawLink;
DocumentFileTree current = document.directory();
for (int i = 0; i < splitPath.length - 1; i++) {
String name = splitPath[i];
switch (name) {
case "" -> {
return rawLink;
}
case "." -> {
continue;
}
case ".." -> {
current = current.getParent();
if (current == null)
return rawLink;
}
default -> {
current = current.getChildren().get(name);
if (current == null)
return rawLink;
}
}
}
DocumentLocale.LocaleAndName currentLocaleAndName = DocumentLocale.parseFileName(fileName.substring(0, fileName.length() - ".md".length()));
LocalizedDocument localizedDocument = current.getFiles().get(currentLocaleAndName.name());
if (localizedDocument != null) {
List<DocumentLocale> candidateLocales = document.locale().getCandidates();
for (DocumentLocale candidateLocale : candidateLocales) {
if (candidateLocale == currentLocaleAndName.locale())
return rawLink;
Document targetDoc = localizedDocument.getDocuments().get(candidateLocale);
if (targetDoc != null) {
splitPath[splitPath.length - 1] = targetDoc.file().getFileName().toString();
return String.join("/", splitPath);
}
}
}
return rawLink;
})).append('\n');
}
private void updateDocument(Document document) throws IOException {
StringBuilder outputBuilder = new StringBuilder(8192);
for (Document.Item item : document.items()) {
if (item instanceof Document.Line line) {
processLine(outputBuilder, line.content(), document);
} else if (item instanceof Document.MacroBlock macro) {
var processor = MacroProcessor.valueOf(macro.name());
processor.apply(document, macro, outputBuilder);
} else
throw new IllegalArgumentException("Unknown item type: " + item.getClass());
}
Files.writeString(document.file(), outputBuilder.toString());
}
private void processDocuments(DocumentFileTree tree) throws IOException {
for (LocalizedDocument localizedDocument : tree.getFiles().values()) {
for (Document document : localizedDocument.getDocuments().values()) {
updateDocument(document);
}
}
for (DocumentFileTree subTree : tree.getChildren().values()) {
processDocuments(subTree);
}
}
@TaskAction
public void run() throws IOException {
Path rootDir = getDocumentsDir().get().getAsFile().toPath();
processDocuments(DocumentFileTree.load(rootDir));
}
}