使用宏自动从英文文档复制部分内容 (#4406)

This commit is contained in:
Glavo
2025-09-08 20:40:23 +08:00
committed by GitHub
parent 24fd2cf521
commit 480f8b6890
14 changed files with 316 additions and 111 deletions

View File

@@ -97,38 +97,46 @@ public record Document(DocumentFileTree directory,
String macroName = matcher.group("name");
String endLine = "<!-- #END " + macroName + " -->";
var properties = new LinkedHashMap<String, List<String>>();
var lines = new ArrayList<String>();
while (true) {
line = reader.readLine();
if (line == null)
throw new IOException("Missing end line for macro: " + macroName);
else if (line.startsWith("<!-- #END")) {
if (line.equals(endLine)) {
break;
} else {
throw new IOException("Invalid macro end line: " + line);
}
} else {
lines.add(line);
}
}
var properties = new LinkedHashMap<String, List<String>>();
int propertiesCount = 0;
// Handle properties
while ((line = reader.readLine()) != null) {
if (!line.startsWith("<!-- #") || line.equals(endLine))
for (String macroBodyLine : lines) {
if (!macroBodyLine.startsWith("<!-- #"))
break;
Matcher propertyMatcher = MACRO_PROPERTY_LINE.matcher(line);
Matcher propertyMatcher = MACRO_PROPERTY_LINE.matcher(macroBodyLine);
if (propertyMatcher.matches()) {
String propertyName = propertyMatcher.group("name");
String propertyValue = parsePropertyValue(propertyMatcher.group("value"));
properties.computeIfAbsent(propertyName, k -> new ArrayList<>(1))
.add(propertyValue);
propertiesCount++;
} else {
throw new IOException("Invalid macro property line: " + line);
throw new IOException("Invalid macro property line: " + macroBodyLine);
}
}
// 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);
if (propertiesCount > 0)
lines.subList(0, propertiesCount).clear();
items.add(new MacroBlock(macroName,
Collections.unmodifiableMap(properties),

View File

@@ -17,14 +17,64 @@
*/
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;
import java.util.*;
import java.util.regex.Pattern;
/// Macro processor for automatically updating documentation.
///
/// Users can use the macro processor in `.md` documents within the `docs` folder and its subfolders.
/// The parts to be processed should be wrapped with `<!-- #BEGIN MACRO_NAME -->` and `<!-- #END MACRO_NAME -->` lines.
///
/// For example, if you create a document `FOO.md` and translate it into Simplified Chinese, Traditional Chinese, and Japanese,
/// you can add the following content in these files to create links to other language versions:
///
/// ```markdown
/// <!-- #BEGIN LANGUAGE_SWITCHER -->
/// <!-- #END LANGUAGE_SWITCHER -->
/// ```
///
/// After running `./gradlew updateDocuments`, the macro processor will automatically update the content between these two lines:
///
/// ```
/// <!-- #BEGIN LANGUAGE_SWITCHER -->
/// **English** | 中文 ([简体](FOO_zh.md), [繁體](FOO_zh_Hant.md)) | [日本語](FOO_ja.md)
/// <!-- #END LANGUAGE_SWITCHER -->
/// ```
///
/// @author Glavo
public enum MacroProcessor {
/// Does not process the content in any way.
///
/// Supported properties:
///
/// - `NAME`: The name of this block (used by other macros).
/// - `PROCESS_LINK`: If set to `FALSE`, document links in the content will not be automatically updated.
BLOCK {
@Override
public void apply(Document document, Document.MacroBlock macroBlock, StringBuilder outputBuilder) throws IOException {
var mutableProperties = new LinkedHashMap<>(macroBlock.properties());
MacroProcessor.removeSingleProperty(mutableProperties, "NAME");
boolean processLink = !"FALSE".equalsIgnoreCase(MacroProcessor.removeSingleProperty(mutableProperties, "PROCESS_LINK"));
if (!mutableProperties.isEmpty())
throw new IllegalArgumentException("Unsupported properties: " + mutableProperties.keySet());
MacroProcessor.writeBegin(outputBuilder, macroBlock);
MacroProcessor.writeProperties(outputBuilder, macroBlock);
for (String line : macroBlock.contentLines()) {
if (processLink)
MacroProcessor.processLine(outputBuilder, line, document);
else
outputBuilder.append(line).append('\n');
}
MacroProcessor.writeEnd(outputBuilder, macroBlock);
}
},
/// Used to automatically generate links to other language versions of the current document.
///
/// Does not support any properties.
LANGUAGE_SWITCHER {
private static <T> boolean containsIdentity(List<T> list, T element) {
for (T t : list) {
@@ -104,17 +154,187 @@ public enum MacroProcessor {
MacroProcessor.writeEnd(outputBuilder, macroBlock);
}
},
BLOCK {
/// Copy the block with the specified name from the English version of the current document.
///
/// Supported properties:
///
/// - `NAME` (required): Specifies the block to be copied.
/// - `REPLACE` (repeatable): Used to replace specified text. Accepts a list containing two strings. The first string is a regular expression for matching content; the second string is the replacement target.
/// - `PROCESS_LINK`: If set to `FALSE`, document links in the content will not be automatically updated.
COPY {
private record Replace(Pattern pattern, String replacement) {
}
private static IllegalArgumentException illegalReplace(String value) {
return new IllegalArgumentException("Illegal replacement pattern: " + value);
}
private static Replace parseReplace(String value) {
List<String> list = MacroProcessor.parseStringList(value);
if (list.size() != 2)
throw illegalReplace(value);
return new Replace(Pattern.compile(list.get(0)), list.get(1));
}
@Override
public void apply(Document document, Document.MacroBlock macroBlock, StringBuilder outputBuilder) throws IOException {
var mutableProperties = new LinkedHashMap<>(macroBlock.properties());
String blockName = MacroProcessor.removeSingleProperty(mutableProperties, "NAME");
if (blockName == null)
throw new IllegalArgumentException("Missing property: NAME");
List<Replace> replaces = Objects.requireNonNullElse(mutableProperties.remove("REPLACE"), List.<String>of())
.stream()
.map(it -> parseReplace(it))
.toList();
boolean processLink = !"FALSE".equalsIgnoreCase(MacroProcessor.removeSingleProperty(mutableProperties, "PROCESS_LINK"));
if (!mutableProperties.isEmpty())
throw new IllegalArgumentException("Unsupported properties: " + mutableProperties.keySet());
LocalizedDocument localizedDocument = document.directory().getFiles().get(document.name());
Document fromDocument;
if (localizedDocument == null || (fromDocument = localizedDocument.getDocuments().get(DocumentLocale.ENGLISH)) == null)
throw new IOException("Document " + document.name() + " for english does not exist");
List<String> nameList = List.of(blockName);
var fromBlock = (Document.MacroBlock) fromDocument.items().stream()
.filter(it -> it instanceof Document.MacroBlock macro
&& macro.name().equals(BLOCK.name())
&& nameList.equals(macro.properties().get("NAME"))
)
.findFirst()
.orElseThrow(() -> new IOException("Cannot find the block \"" + blockName + "\" in " + fromDocument.file()));
MacroProcessor.writeBegin(outputBuilder, macroBlock);
MacroProcessor.writeProperties(outputBuilder, macroBlock);
for (String line : macroBlock.contentLines()) {
outputBuilder.append(line).append('\n');
for (String line : fromBlock.contentLines()) {
for (Replace replace : replaces) {
line = replace.pattern.matcher(line).replaceAll(replace.replacement());
}
if (processLink)
processLine(outputBuilder, line, document);
else
outputBuilder.append(line).append('\n');
}
MacroProcessor.writeEnd(outputBuilder, macroBlock);
}
};
},
;
private static String removeSingleProperty(Map<String, List<String>> properties, String name) {
List<String> values = properties.remove(name);
if (values == null || values.isEmpty())
return null;
if (values.size() != 1)
throw new IllegalArgumentException("Unexpected number of property " + name + ": " + values.size());
return values.get(0);
}
private static List<String> parseStringList(String str) {
if (str.isBlank()) {
return new ArrayList<>();
}
// Split the string with ' and space cleverly.
ArrayList<String> parts = new ArrayList<>(2);
boolean hasValue = false;
StringBuilder current = new StringBuilder(str.length());
for (int i = 0; i < str.length(); ) {
char c = str.charAt(i);
if (c == '\'' || c == '"') {
hasValue = true;
int end = str.indexOf(c, i + 1);
if (end < 0) {
end = str.length();
}
current.append(str, i + 1, end);
i = end + 1;
} else if (c == ' ' || c == '\t') {
if (hasValue) {
parts.add(current.toString());
current.setLength(0);
hasValue = false;
}
i++;
} else {
hasValue = true;
current.append(c);
i++;
}
}
if (hasValue)
parts.add(current.toString());
return parts;
}
private static final Pattern LINK_PATTERN = Pattern.compile(
"(?<=]\\()[a-zA-Z0-9_\\-./]+\\.md(?=\\))"
);
static 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 static void writeBegin(StringBuilder builder, Document.MacroBlock macroBlock) throws IOException {
builder.append("<!-- #BEGIN ");

View File

@@ -25,8 +25,6 @@ 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 {
@@ -36,71 +34,12 @@ public abstract class UpdateDocuments extends DefaultTask {
// ---
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);
MacroProcessor.processLine(outputBuilder, line.content(), document);
} else if (item instanceof Document.MacroBlock macro) {
var processor = MacroProcessor.valueOf(macro.name());
processor.apply(document, macro, outputBuilder);