使用 Gradle 自动更新文档内容 (#4403)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import org.jackhuang.hmcl.gradle.docs.UpdateDocuments
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("checkstyle")
|
id("checkstyle")
|
||||||
}
|
}
|
||||||
@@ -60,3 +62,8 @@ subprojects {
|
|||||||
org.jackhuang.hmcl.gradle.javafx.JavaFXUtils.register(rootProject)
|
org.jackhuang.hmcl.gradle.javafx.JavaFXUtils.register(rootProject)
|
||||||
|
|
||||||
defaultTasks("clean", "build")
|
defaultTasks("clean", "build")
|
||||||
|
|
||||||
|
|
||||||
|
tasks.register<UpdateDocuments>("updateDocuments") {
|
||||||
|
documentsDir.set(layout.projectDirectory.dir("docs"))
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 + ']';
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# Platform Support Status
|
# Platform Support Status
|
||||||
|
|
||||||
|
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||||
**English** | 中文 ([简体](PLATFORM_zh.md), [繁體](PLATFORM_zh_Hant.md))
|
**English** | 中文 ([简体](PLATFORM_zh.md), [繁體](PLATFORM_zh_Hant.md))
|
||||||
|
<!-- #END LANGUAGE_SWITCHER -->
|
||||||
|
|
||||||
| | Windows | Linux | macOS | FreeBSD |
|
| | Windows | Linux | macOS | FreeBSD |
|
||||||
|----------------------------|:--------------------------------------------------|:---------------------------|:------------------------------------------------------------------------|:---------------------------|
|
|----------------------------|:--------------------------------------------------|:---------------------------|:------------------------------------------------------------------------|:---------------------------|
|
||||||
@@ -35,4 +37,4 @@ Legend:
|
|||||||
* `/`: Not applicable.
|
* `/`: Not applicable.
|
||||||
|
|
||||||
We have no plans to support these platforms at this time, mainly because we do not have the equipment to test them.
|
We have no plans to support these platforms at this time, mainly because we do not have the equipment to test them.
|
||||||
If you can help us adapt, please file a support request via GitHub Issue.
|
If you can help us adapt, please file a support request via GitHub Issue.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# 平台支持状态
|
# 平台支持状态
|
||||||
|
|
||||||
|
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||||
[English](PLATFORM.md) | **中文** (**简体**, [繁體](PLATFORM_zh_Hant.md))
|
[English](PLATFORM.md) | **中文** (**简体**, [繁體](PLATFORM_zh_Hant.md))
|
||||||
|
<!-- #END LANGUAGE_SWITCHER -->
|
||||||
|
|
||||||
| | Windows | Linux | macOS | FreeBSD |
|
| | Windows | Linux | macOS | FreeBSD |
|
||||||
|----------------------------|:--------------------------------------------------|:---------------------------|:-----------------------------------------------------------------------|:--------------------------|
|
|----------------------------|:--------------------------------------------------|:---------------------------|:-----------------------------------------------------------------------|:--------------------------|
|
||||||
@@ -35,4 +37,4 @@
|
|||||||
* `/`: 不支持的平台
|
* `/`: 不支持的平台
|
||||||
|
|
||||||
我们目前还没有打算支持这些平台,主要是因为我们没有测试这些平台的设备。
|
我们目前还没有打算支持这些平台,主要是因为我们没有测试这些平台的设备。
|
||||||
如果你能帮助我们进行测试,请通过提交 Issue 提出支持请求。
|
如果你能帮助我们进行测试,请通过提交 Issue 提出支持请求。
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# 平臺支援狀態
|
# 平臺支援狀態
|
||||||
|
|
||||||
|
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||||
[English](PLATFORM.md) | **中文** ([简体](PLATFORM_zh.md), **繁體**)
|
[English](PLATFORM.md) | **中文** ([简体](PLATFORM_zh.md), **繁體**)
|
||||||
|
<!-- #END LANGUAGE_SWITCHER -->
|
||||||
|
|
||||||
| | Windows | Linux | macOS | FreeBSD |
|
| | Windows | Linux | macOS | FreeBSD |
|
||||||
|----------------------------|:--------------------------------------------------|:---------------------------|:-----------------------------------------------------------------------|:--------------------------|
|
|----------------------------|:--------------------------------------------------|:---------------------------|:-----------------------------------------------------------------------|:--------------------------|
|
||||||
@@ -35,4 +37,4 @@
|
|||||||
* `/`: 不支援的平臺
|
* `/`: 不支援的平臺
|
||||||
|
|
||||||
我們目前還沒有打算支援這些平臺,主要是因為我們沒有測試這些平臺的裝置。
|
我們目前還沒有打算支援這些平臺,主要是因為我們沒有測試這些平臺的裝置。
|
||||||
如果你能幫助我們進行測試,請透過提交 Issue 提出支援請求。
|
如果你能幫助我們進行測試,請透過提交 Issue 提出支援請求。
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
[](https://discord.gg/jVvC7HfM6U)
|
[](https://discord.gg/jVvC7HfM6U)
|
||||||
[](https://docs.hmcl.net/groups.html)
|
[](https://docs.hmcl.net/groups.html)
|
||||||
|
|
||||||
**English** | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) |
|
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||||
[español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
|
**English** | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
|
||||||
|
<!-- #END LANGUAGE_SWITCHER -->
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
[](https://discord.gg/jVvC7HfM6U)
|
[](https://discord.gg/jVvC7HfM6U)
|
||||||
[](https://docs.hmcl.net/groups.html)
|
[](https://docs.hmcl.net/groups.html)
|
||||||
|
|
||||||
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) |
|
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||||
**español** | [русский](README_ru.md) | [українська](README_uk.md)
|
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | **español** | [русский](README_ru.md) | [українська](README_uk.md)
|
||||||
|
<!-- #END LANGUAGE_SWITCHER -->
|
||||||
|
|
||||||
## Introducción
|
## Introducción
|
||||||
|
|
||||||
@@ -61,4 +62,4 @@ Asegúrate de tener instalado Java 17 o una versión posterior.
|
|||||||
| `-Dhmcl.native.encoding=<codificación>` | Sobrescribe la codificación nativa |
|
| `-Dhmcl.native.encoding=<codificación>` | Sobrescribe la codificación nativa |
|
||||||
| `-Dhmcl.microsoft.auth.id=<ID de App>` | Sobrescribe el ID de la App OAuth de Microsoft |
|
| `-Dhmcl.microsoft.auth.id=<ID de App>` | Sobrescribe el ID de la App OAuth de Microsoft |
|
||||||
| `-Dhmcl.microsoft.auth.secret=<Secreto de App>` | Sobrescribe el secreto de la App OAuth de Microsoft |
|
| `-Dhmcl.microsoft.auth.secret=<Secreto de App>` | Sobrescribe el secreto de la App OAuth de Microsoft |
|
||||||
| `-Dhmcl.curseforge.apikey=<Clave API>` | Sobrescribe la clave API de CurseForge |
|
| `-Dhmcl.curseforge.apikey=<Clave API>` | Sobrescribe la clave API de CurseForge |
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
[](https://discord.gg/jVvC7HfM6U)
|
[](https://discord.gg/jVvC7HfM6U)
|
||||||
[](https://docs.hmcl.net/groups.html)
|
[](https://docs.hmcl.net/groups.html)
|
||||||
|
|
||||||
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | **日本語** |
|
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||||
[español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
|
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | **日本語** | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
|
||||||
|
<!-- #END LANGUAGE_SWITCHER -->
|
||||||
|
|
||||||
## 紹介
|
## 紹介
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
[](https://discord.gg/jVvC7HfM6U)
|
[](https://discord.gg/jVvC7HfM6U)
|
||||||
[](https://docs.hmcl.net/groups.html)
|
[](https://docs.hmcl.net/groups.html)
|
||||||
|
|
||||||
[English](README.md) | **中文** ([简体](README_zh.md), [繁體](README_zh_Hant.md), **文言**) | [日本語](README_ja.md) |
|
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||||
[español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
|
[English](README.md) | **中文** ([简体](README_zh.md), [繁體](README_zh_Hant.md), **文言**) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
|
||||||
|
<!-- #END LANGUAGE_SWITCHER -->
|
||||||
|
|
||||||
### 概說
|
### 概說
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
[](https://discord.gg/jVvC7HfM6U)
|
[](https://discord.gg/jVvC7HfM6U)
|
||||||
[](https://docs.hmcl.net/groups.html)
|
[](https://docs.hmcl.net/groups.html)
|
||||||
|
|
||||||
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) |
|
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||||
[español](README_es.md) | **русский** | [українська](README_uk.md)
|
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | **русский** | [українська](README_uk.md)
|
||||||
|
<!-- #END LANGUAGE_SWITCHER -->
|
||||||
|
|
||||||
## Введение
|
## Введение
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
[](https://discord.gg/jVvC7HfM6U)
|
[](https://discord.gg/jVvC7HfM6U)
|
||||||
[](https://docs.hmcl.net/groups.html)
|
[](https://docs.hmcl.net/groups.html)
|
||||||
|
|
||||||
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) |
|
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||||
[español](README_es.md) | [русский](README_ru.md) | **українська**
|
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | **українська**
|
||||||
|
<!-- #END LANGUAGE_SWITCHER -->
|
||||||
|
|
||||||
## Вступ
|
## Вступ
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
[](https://discord.gg/jVvC7HfM6U)
|
[](https://discord.gg/jVvC7HfM6U)
|
||||||
[](https://docs.hmcl.net/groups.html)
|
[](https://docs.hmcl.net/groups.html)
|
||||||
|
|
||||||
[English](README.md) | **中文** (**简体**, [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) |
|
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||||
[español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
|
[English](README.md) | **中文** (**简体**, [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
|
||||||
|
<!-- #END LANGUAGE_SWITCHER -->
|
||||||
|
|
||||||
## 简介
|
## 简介
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
[](https://discord.gg/jVvC7HfM6U)
|
[](https://discord.gg/jVvC7HfM6U)
|
||||||
[](https://docs.hmcl.net/groups.html)
|
[](https://docs.hmcl.net/groups.html)
|
||||||
|
|
||||||
[English](README.md) | **中文** ([简体](README_zh.md), **繁體**, [文言](README_lzh.md)) | [日本語](README_ja.md) |
|
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||||
[español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
|
[English](README.md) | **中文** ([简体](README_zh.md), **繁體**, [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
|
||||||
|
<!-- #END LANGUAGE_SWITCHER -->
|
||||||
|
|
||||||
## 簡介
|
## 簡介
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user