Merge branch 'main' into main

This commit is contained in:
郝某人BH 2025-09-08 18:35:36 +08:00 committed by GitHub
commit 80c92904ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 967 additions and 115 deletions

View File

@ -236,6 +236,10 @@ public final class ModpackHelper {
if (provider == null) {
throw new UnsupportedModpackException();
}
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack)
.thenComposeAsync(() -> createMultiMCPostUpdateTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name));
else
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack);
}
@ -276,6 +280,24 @@ public final class ModpackHelper {
}
}
private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, VersionSetting vs) {
if (c.isOverrideCommands()) {
vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), ""));
vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), ""));
}
if (c.isOverrideJavaArgs()) {
vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), ""));
}
}
private static Task<Void> createMultiMCPostUpdateTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
return Task.runAsync(Schedulers.javafx(), () -> {
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
ModpackHelper.applyCommandAndJvmSettings(manifest, vs);
});
}
private static Task<Void> createMultiMCPostInstallTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
return Task.runAsync(Schedulers.javafx(), () -> {
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));

View File

@ -52,6 +52,7 @@ import javafx.stage.Stage;
import javafx.util.Callback;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.task.CacheFileTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@ -1159,6 +1160,13 @@ public final class FXUtils {
return button;
}
public static JFXButton newToggleButton4(SVG icon) {
JFXButton button = new JFXButton();
button.getStyleClass().add("toggle-icon4");
button.setGraphic(icon.createIcon(Theme.blackFill(), -1));
return button;
}
public static Label truncatedLabel(String text, int limit) {
Label label = new Label();
if (text.length() <= limit) {

View File

@ -19,12 +19,13 @@ package org.jackhuang.hmcl.ui.download;
import com.jfoenix.controls.*;
import javafx.beans.InvalidationListener;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
@ -41,7 +42,6 @@ import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltAPIRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltRemoteVersion;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@ -73,17 +73,23 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
private final String libraryId;
private final String title;
private final Navigation navigation;
private final DownloadProvider downloadProvider;
private final VersionList<?> versionList;
private final Runnable callback;
private final ObservableList<RemoteVersion> versions = FXCollections.observableArrayList();
private final ObjectProperty<Status> status = new SimpleObjectProperty<>(Status.LOADING);
public VersionsPage(Navigation navigation, String title, String gameVersion, DownloadProvider downloadProvider, String libraryId, Runnable callback) {
public VersionsPage(Navigation navigation,
String title, String gameVersion,
DownloadProvider downloadProvider,
String libraryId,
Runnable callback) {
this.title = title;
this.gameVersion = gameVersion;
this.libraryId = libraryId;
this.navigation = navigation;
this.downloadProvider = downloadProvider;
this.versionList = downloadProvider.getVersionListById(libraryId);
this.callback = callback;
@ -130,10 +136,6 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
navigation.onPrev(true);
}
private void onSponsor() {
FXUtils.openLink("https://bmclapidoc.bangbang93.com");
}
private enum Status {
LOADING,
FAILED,
@ -149,22 +151,61 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
}
private static class RemoteVersionListCell extends ListCell<RemoteVersion> {
private final IconedTwoLineListItem content = new IconedTwoLineListItem();
private final RipplerContainer ripplerContainer = new RipplerContainer(content);
private final VersionsPage control;
private final TwoLineListItem twoLineListItem = new TwoLineListItem();
private final ImageView imageView = new ImageView();
private final StackPane pane = new StackPane();
private final Holder<RemoteVersionListCell> lastCell;
RemoteVersionListCell(Holder<RemoteVersionListCell> lastCell, String libraryId) {
RemoteVersionListCell(Holder<RemoteVersionListCell> lastCell, VersionsPage control) {
this.lastCell = lastCell;
if ("game".equals(libraryId)) {
content.getExternalLinkButton().setGraphic(SVG.GLOBE_BOOK.createIcon(Theme.blackFill(), -1));
FXUtils.installFastTooltip(content.getExternalLinkButton(), i18n("wiki.tooltip"));
this.control = control;
HBox hbox = new HBox(16);
HBox.setHgrow(twoLineListItem, Priority.ALWAYS);
hbox.setAlignment(Pos.CENTER);
HBox actions = new HBox(8);
actions.setAlignment(Pos.CENTER);
{
if ("game".equals(control.libraryId)) {
JFXButton wikiButton = newToggleButton4(SVG.GLOBE_BOOK);
wikiButton.setOnAction(event -> onOpenWiki());
FXUtils.installFastTooltip(wikiButton, i18n("wiki.tooltip"));
actions.getChildren().add(wikiButton);
}
JFXButton actionButton = newToggleButton4(SVG.ARROW_FORWARD);
actionButton.setOnAction(e -> onAction());
actions.getChildren().add(actionButton);
}
hbox.getChildren().setAll(imageView, twoLineListItem, actions);
pane.getStyleClass().add("md-list-cell");
StackPane.setMargin(content, new Insets(10, 16, 10, 16));
pane.getChildren().setAll(ripplerContainer);
StackPane.setMargin(hbox, new Insets(10, 16, 10, 16));
pane.getChildren().setAll(new RipplerContainer(hbox));
FXUtils.onClicked(this, this::onAction);
}
private void onAction() {
RemoteVersion item = getItem();
if (item == null)
return;
control.navigation.getSettings().put(control.libraryId, item);
control.callback.run();
}
private void onOpenWiki() {
RemoteVersion item = getItem();
if (!(item instanceof GameRemoteVersion))
return;
FXUtils.openLink(I18n.getWikiLink((GameRemoteVersion) item));
}
@Override
@ -182,37 +223,36 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
}
setGraphic(pane);
content.setTitle(I18n.getDisplaySelfVersion(remoteVersion));
twoLineListItem.setTitle(I18n.getDisplaySelfVersion(remoteVersion));
if (remoteVersion.getReleaseDate() != null) {
content.setSubtitle(I18n.formatDateTime(remoteVersion.getReleaseDate()));
twoLineListItem.setSubtitle(I18n.formatDateTime(remoteVersion.getReleaseDate()));
} else {
content.setSubtitle(null);
twoLineListItem.setSubtitle(null);
}
if (remoteVersion instanceof GameRemoteVersion) {
RemoteVersion.Type versionType = remoteVersion.getVersionType();
switch (versionType) {
case RELEASE:
content.getTags().setAll(i18n("version.game.release"));
content.setImage(VersionIconType.GRASS.getIcon());
twoLineListItem.getTags().setAll(i18n("version.game.release"));
imageView.setImage(VersionIconType.GRASS.getIcon());
break;
case PENDING:
case SNAPSHOT:
if (versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) {
content.getTags().setAll(i18n("version.game.april_fools"));
content.setImage(VersionIconType.APRIL_FOOLS.getIcon());
twoLineListItem.getTags().setAll(i18n("version.game.april_fools"));
imageView.setImage(VersionIconType.APRIL_FOOLS.getIcon());
} else {
content.getTags().setAll(i18n("version.game.snapshot"));
content.setImage(VersionIconType.COMMAND.getIcon());
twoLineListItem.getTags().setAll(i18n("version.game.snapshot"));
imageView.setImage(VersionIconType.COMMAND.getIcon());
}
break;
default:
content.getTags().setAll(i18n("version.game.old"));
content.setImage(VersionIconType.CRAFT_TABLE.getIcon());
twoLineListItem.getTags().setAll(i18n("version.game.old"));
imageView.setImage(VersionIconType.CRAFT_TABLE.getIcon());
break;
}
content.setExternalLink(I18n.getWikiLink((GameRemoteVersion) remoteVersion));
} else {
VersionIconType iconType;
if (remoteVersion instanceof LiteLoaderRemoteVersion)
@ -230,14 +270,13 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
else if (remoteVersion instanceof QuiltRemoteVersion || remoteVersion instanceof QuiltAPIRemoteVersion)
iconType = VersionIconType.QUILT;
else
iconType = null;
iconType = VersionIconType.COMMAND;
content.setImage(iconType != null ? iconType.getIcon() : null);
if (content.getSubtitle() == null)
content.setSubtitle(remoteVersion.getGameVersion());
imageView.setImage(iconType.getIcon());
if (twoLineListItem.getSubtitle() == null)
twoLineListItem.setSubtitle(remoteVersion.getGameVersion());
else
content.getTags().setAll(remoteVersion.getGameVersion());
content.setExternalLink(null);
twoLineListItem.getTags().setAll(remoteVersion.getGameVersion());
}
}
}
@ -355,14 +394,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
control.versions.addListener((InvalidationListener) o -> updateList());
Holder<RemoteVersionListCell> lastCell = new Holder<>();
list.setCellFactory(listView -> new RemoteVersionListCell(lastCell, control.libraryId));
FXUtils.onClicked(list, () -> {
if (list.getSelectionModel().getSelectedIndex() < 0)
return;
control.navigation.getSettings().put(control.libraryId, list.getSelectionModel().getSelectedItem());
control.callback.run();
});
list.setCellFactory(listView -> new RemoteVersionListCell(lastCell, control));
ComponentList.setVgrow(list, Priority.ALWAYS);

View File

@ -37,6 +37,7 @@ import org.jackhuang.hmcl.util.Restarter;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.Locales;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.logging.Level;
import org.tukaani.xz.XZInputStream;
@ -44,11 +45,8 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
@ -170,6 +168,42 @@ public final class SettingsPage extends SettingsView {
UpdateHandler.updateFrom(target);
}
/// This method guarantees to close both `input` and the current zip entry.
///
/// If no exception occurs, this method returns `true`;
/// If an exception occurs while reading from `input`, this method returns `false`;
/// If an exception occurs while writing to `output`, this method will throw it as is.
private static boolean exportLogFile(ZipOutputStream output,
Path file, // For logging
String entryName,
InputStream input,
byte[] buffer) throws IOException {
//noinspection TryFinallyCanBeTryWithResources
try {
output.putNextEntry(new ZipEntry(entryName));
int read;
while (true) {
try {
read = input.read(buffer);
if (read <= 0)
return true;
} catch (Throwable ex) {
LOG.warning("Failed to decompress log file " + file, ex);
return false;
}
output.write(buffer, 0, read);
}
} finally {
try {
input.close();
} catch (Throwable ex) {
LOG.warning("Failed to close log file " + file, ex);
}
output.closeEntry();
}
}
@Override
protected void onExportLogs() {
thread(() -> {
@ -190,45 +224,54 @@ public final class SettingsPage extends SettingsView {
LOG.info("Exporting latest logs to " + outputFile);
Path tempFile = Files.createTempFile("hmcl-decompress-log-", ".txt");
try (var tempChannel = FileChannel.open(tempFile, StandardOpenOption.READ, StandardOpenOption.WRITE);
var os = Files.newOutputStream(outputFile);
byte[] buffer = new byte[IOUtils.DEFAULT_BUFFER_SIZE];
try (var os = Files.newOutputStream(outputFile);
var zos = new ZipOutputStream(os)) {
for (Path path : recentLogFiles) {
String extension = FileUtils.getExtension(path);
decompress:
if ("gz".equalsIgnoreCase(extension) || "xz".equalsIgnoreCase(extension)) {
try (InputStream fis = Files.newInputStream(path);
InputStream uncompressed = "gz".equalsIgnoreCase(extension)
? new GZIPInputStream(fis)
: new XZInputStream(fis)) {
uncompressed.transferTo(Channels.newOutputStream(tempChannel));
} catch (IOException e) {
LOG.warning("Failed to decompress log: " + path, e);
break decompress;
String fileName = FileUtils.getName(path);
String extension = StringUtils.substringAfterLast(fileName, '.');
if ("gz".equals(extension) || "xz".equals(extension)) {
// If an exception occurs while decompressing the input file, we should
// ensure the input file and the current zip entry are closed,
// then copy the compressed file content as-is into a new entry in the zip file.
InputStream input = null;
try {
input = Files.newInputStream(path);
input = "gz".equals(extension)
? new GZIPInputStream(input)
: new XZInputStream(input);
} catch (Throwable ex) {
LOG.warning("Failed to open log file " + path, ex);
IOUtils.closeQuietly(input, ex);
input = null;
}
zos.putNextEntry(new ZipEntry(StringUtils.substringBeforeLast(FileUtils.getName(path), '.')));
Channels.newInputStream(tempChannel).transferTo(zos);
zos.closeEntry();
tempChannel.truncate(0);
String entryName = StringUtils.substringBeforeLast(fileName, ".");
if (input != null && exportLogFile(zos, path, entryName, input, buffer))
continue;
}
zos.putNextEntry(new ZipEntry(FileUtils.getName(path)));
Files.copy(path, zos);
zos.closeEntry();
// Copy the log file content as-is into a new entry in the zip file.
// If an exception occurs while decompressing the input file, we should
// ensure the input file and the current zip entry are closed.
InputStream input;
try {
input = Files.newInputStream(path);
} catch (Throwable ex) {
LOG.warning("Failed to open log file " + path, ex);
continue;
}
exportLogFile(zos, path, fileName, input, buffer);
}
zos.putNextEntry(new ZipEntry("hmcl-latest.log"));
LOG.exportLogs(zos);
zos.closeEntry();
} finally {
try {
Files.deleteIfExists(tempFile);
} catch (IOException ignored) {
}
}
}
} catch (IOException e) {

View File

@ -39,7 +39,6 @@ import org.jackhuang.hmcl.ui.construct.ComponentSublist;
import org.jackhuang.hmcl.ui.construct.MultiFileItem;
import org.jackhuang.hmcl.ui.construct.OptionToggleButton;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.Locales;
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
import java.util.Arrays;
@ -183,8 +182,10 @@ public abstract class SettingsView extends StackPane {
SupportedLocale currentLocale = I18n.getLocale();
cboLanguage = new JFXComboBox<>();
cboLanguage.setConverter(stringConverter(locale -> {
if (locale.isSameLanguage(currentLocale) || locale == Locales.DEFAULT)
if (locale.isDefault())
return locale.getDisplayName(currentLocale);
else if (locale.isSameLanguage(currentLocale))
return locale.getDisplayName(locale);
else
return locale.getDisplayName(currentLocale) + " - " + locale.getDisplayName(locale);
}));

View File

@ -44,13 +44,8 @@ public final class Locales {
public static final SupportedLocale DEFAULT = new SupportedLocale("def", getDefaultLocale()) {
@Override
public String getDisplayName(SupportedLocale inLocale) {
try {
return inLocale.getResourceBundle().getString("lang.default");
} catch (Throwable e) {
LOG.warning("Failed to get localized name for default locale", e);
return "Default";
}
public boolean isDefault() {
return true;
}
};
@ -92,19 +87,7 @@ public final class Locales {
/**
* Wenyan (Classical Chinese)
*/
public static final SupportedLocale WENYAN = new SupportedLocale("lzh", Locale.forLanguageTag("lzh")) {
@Override
public String getDisplayName(SupportedLocale inLocale) {
if (LocaleUtils.isChinese(inLocale.locale))
return "文言";
String name = super.getDisplayName(inLocale);
return name.equals("lzh") || name.equals("Literary Chinese")
? "Chinese (Classical)"
: name;
}
};
public static final SupportedLocale WENYAN = new SupportedLocale("lzh", Locale.forLanguageTag("lzh"));
public static final List<SupportedLocale> LOCALES = List.of(DEFAULT, EN, ES, JA, RU, UK, ZH_HANS, ZH_HANT, WENYAN);
@ -132,6 +115,10 @@ public final class Locales {
this.locale = locale;
}
public boolean isDefault() {
return false;
}
public String getName() {
return name;
}
@ -143,6 +130,26 @@ public final class Locales {
public String getDisplayName(SupportedLocale inLocale) {
if (inLocale.locale.getLanguage().equals("lzh"))
inLocale = ZH_HANT;
if (isDefault()) {
try {
return inLocale.getResourceBundle().getString("lang.default");
} catch (Throwable e) {
LOG.warning("Failed to get localized name for default locale", e);
return "Default";
}
}
if (this.locale.getLanguage().equals("lzh")) {
if (LocaleUtils.isChinese(inLocale.locale))
return "文言";
String name = locale.getDisplayName(inLocale.getLocale());
return name.equals("lzh") || name.equals("Literary Chinese")
? "Chinese (Classical)"
: name;
}
return locale.getDisplayName(inLocale.getLocale());
}

View File

@ -44,7 +44,7 @@ public final class WenyanUtils {
};
private static final char[] TIAN_GAN = {'甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'};
private static final char[] DI_ZHI = {'子', '', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'};
private static final char[] DI_ZHI = {'子', '', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'};
private static String digitToString(char digit) {
return digit >= '0' && digit <= '9'

View File

@ -1,3 +1,5 @@
import org.jackhuang.hmcl.gradle.docs.UpdateDocuments
plugins {
id("checkstyle")
}
@ -60,3 +62,8 @@ subprojects {
org.jackhuang.hmcl.gradle.javafx.JavaFXUtils.register(rootProject)
defaultTasks("clean", "build")
tasks.register<UpdateDocuments>("updateDocuments") {
documentsDir.set(layout.projectDirectory.dir("docs"))
}

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));
}
}

View File

@ -1,6 +1,8 @@
# Platform Support Status
<!-- #BEGIN LANGUAGE_SWITCHER -->
**English** | 中文 ([简体](PLATFORM_zh.md), [繁體](PLATFORM_zh_Hant.md))
<!-- #END LANGUAGE_SWITCHER -->
| | Windows | Linux | macOS | FreeBSD |
|----------------------------|:--------------------------------------------------|:---------------------------|:------------------------------------------------------------------------|:---------------------------|

View File

@ -1,6 +1,8 @@
# 平台支持状态
<!-- #BEGIN LANGUAGE_SWITCHER -->
[English](PLATFORM.md) | **中文** (**简体**, [繁體](PLATFORM_zh_Hant.md))
<!-- #END LANGUAGE_SWITCHER -->
| | Windows | Linux | macOS | FreeBSD |
|----------------------------|:--------------------------------------------------|:---------------------------|:-----------------------------------------------------------------------|:--------------------------|

View File

@ -1,6 +1,8 @@
# 平臺支援狀態
<!-- #BEGIN LANGUAGE_SWITCHER -->
[English](PLATFORM.md) | **中文** ([简体](PLATFORM_zh.md), **繁體**)
<!-- #END LANGUAGE_SWITCHER -->
| | Windows | Linux | macOS | FreeBSD |
|----------------------------|:--------------------------------------------------|:---------------------------|:-----------------------------------------------------------------------|:--------------------------|

View File

@ -6,8 +6,9 @@
[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U)
[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html)
**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)
<!-- #BEGIN LANGUAGE_SWITCHER -->
**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

View File

@ -6,8 +6,9 @@
[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U)
[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html)
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) |
**español** | [русский](README_ru.md) | [українська](README_uk.md)
<!-- #BEGIN LANGUAGE_SWITCHER -->
[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

View File

@ -6,8 +6,9 @@
[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U)
[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html)
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | **日本語** |
[español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
<!-- #BEGIN LANGUAGE_SWITCHER -->
[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 -->
## 紹介

View File

@ -6,8 +6,9 @@
[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U)
[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html)
[English](README.md) | **中文** ([简体](README_zh.md), [繁體](README_zh_Hant.md), **文言**) | [日本語](README_ja.md) |
[español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
<!-- #BEGIN LANGUAGE_SWITCHER -->
[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 -->
### 概說

View File

@ -6,8 +6,9 @@
[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U)
[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html)
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) |
[español](README_es.md) | **русский** | [українська](README_uk.md)
<!-- #BEGIN LANGUAGE_SWITCHER -->
[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 -->
## Введение

View File

@ -6,8 +6,9 @@
[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U)
[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html)
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) |
[español](README_es.md) | [русский](README_ru.md) | **українська**
<!-- #BEGIN LANGUAGE_SWITCHER -->
[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 -->
## Вступ

View File

@ -6,8 +6,9 @@
[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U)
[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html)
[English](README.md) | **中文** (**简体**, [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) |
[español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
<!-- #BEGIN LANGUAGE_SWITCHER -->
[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 -->
## 简介

View File

@ -6,8 +6,9 @@
[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U)
[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html)
[English](README.md) | **中文** ([简体](README_zh.md), **繁體**, [文言](README_lzh.md)) | [日本語](README_ja.md) |
[español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
<!-- #BEGIN LANGUAGE_SWITCHER -->
[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 -->
## 簡介