@v1hz/md2docx 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/config/config.json +34 -0
- package/config/config.schema.json +173 -0
- package/package.json +60 -0
- package/src/cli.ts +200 -0
- package/src/config.ts +52 -0
- package/src/index.ts +63 -0
- package/src/paths.ts +32 -0
- package/src/preprocess/caption.ts +107 -0
- package/src/preprocess/index.ts +49 -0
- package/src/preprocess/mermaid.ts +146 -0
- package/src/preprocess/title.ts +144 -0
- package/src/style/extract.ts +381 -0
- package/src/web/app.css +298 -0
- package/src/web/app.js +181 -0
- package/src/web/index.html +27 -0
- package/src/web.ts +148 -0
package/src/web/app.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
const form = document.querySelector("#config-form");
|
|
2
|
+
const index = document.querySelector("#config-index");
|
|
3
|
+
const status = document.querySelector("#status");
|
|
4
|
+
const saveButton = document.querySelector("#save-button");
|
|
5
|
+
const resetButton = document.querySelector("#reset-button");
|
|
6
|
+
|
|
7
|
+
let schema;
|
|
8
|
+
let originalConfig;
|
|
9
|
+
let currentConfig;
|
|
10
|
+
|
|
11
|
+
initialize();
|
|
12
|
+
|
|
13
|
+
async function initialize() {
|
|
14
|
+
try {
|
|
15
|
+
const response = await fetch("/api/config");
|
|
16
|
+
if (!response.ok) throw new Error("无法读取配置");
|
|
17
|
+
({ schema, config: originalConfig } = await response.json());
|
|
18
|
+
delete originalConfig.$schema;
|
|
19
|
+
currentConfig = structuredClone(originalConfig);
|
|
20
|
+
enforceHeadingDependency(originalConfig);
|
|
21
|
+
enforceHeadingDependency(currentConfig);
|
|
22
|
+
renderForm();
|
|
23
|
+
updateView();
|
|
24
|
+
} catch (error) {
|
|
25
|
+
form.innerHTML = `<div class="loading">${escapeHtml(error.message)}</div>`;
|
|
26
|
+
setStatus(error.message, true);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function renderForm() {
|
|
31
|
+
form.innerHTML = "";
|
|
32
|
+
index.innerHTML = "";
|
|
33
|
+
for (const [groupName, groupSchema] of Object.entries(schema.properties)) {
|
|
34
|
+
const title = groupSchema.description ?? groupName;
|
|
35
|
+
const sectionId = `section-${groupName}`;
|
|
36
|
+
const link = document.createElement("a");
|
|
37
|
+
link.href = `#${sectionId}`;
|
|
38
|
+
link.textContent = title;
|
|
39
|
+
index.append(link);
|
|
40
|
+
|
|
41
|
+
const group = document.createElement("section");
|
|
42
|
+
group.className = "group";
|
|
43
|
+
group.id = sectionId;
|
|
44
|
+
group.innerHTML = `<header class="group-title"><h2>${escapeHtml(title)}</h2></header><div class="fields"></div>`;
|
|
45
|
+
const fields = group.querySelector(".fields");
|
|
46
|
+
for (const [name, fieldSchema] of Object.entries(groupSchema.properties)) {
|
|
47
|
+
fields.append(createField(groupName, name, fieldSchema));
|
|
48
|
+
}
|
|
49
|
+
form.append(group);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function createField(groupName, name, fieldSchema) {
|
|
54
|
+
const path = `${groupName}.${name}`;
|
|
55
|
+
const field = document.createElement("div");
|
|
56
|
+
field.className = "field";
|
|
57
|
+
field.innerHTML = `<label for="${path}">${escapeHtml(fieldSchema.description ?? name)}</label>`;
|
|
58
|
+
|
|
59
|
+
let input;
|
|
60
|
+
if (fieldSchema.type === "boolean") {
|
|
61
|
+
const wrapper = document.createElement("label");
|
|
62
|
+
wrapper.className = "switch";
|
|
63
|
+
wrapper.innerHTML = `<input id="${path}" type="checkbox" ${getPath(currentConfig, path) ? "checked" : ""}><span class="switch-track"></span>`;
|
|
64
|
+
input = wrapper.querySelector("input");
|
|
65
|
+
if (path === "normalizeHeadings.enabled" && currentConfig.numberHeadings.enabled) {
|
|
66
|
+
input.disabled = true;
|
|
67
|
+
input.title = "标题编号开启时无法关闭";
|
|
68
|
+
}
|
|
69
|
+
field.append(wrapper);
|
|
70
|
+
} else if (fieldSchema.enum) {
|
|
71
|
+
input = document.createElement("select");
|
|
72
|
+
input.id = path;
|
|
73
|
+
for (const value of fieldSchema.enum) {
|
|
74
|
+
const option = document.createElement("option");
|
|
75
|
+
option.value = value;
|
|
76
|
+
option.textContent = fieldSchema.enumDescriptions?.[value] ?? value;
|
|
77
|
+
option.selected = value === getPath(currentConfig, path);
|
|
78
|
+
input.append(option);
|
|
79
|
+
}
|
|
80
|
+
field.append(input);
|
|
81
|
+
} else {
|
|
82
|
+
input = document.createElement("input");
|
|
83
|
+
input.id = path;
|
|
84
|
+
input.type =
|
|
85
|
+
fieldSchema.type === "integer" || fieldSchema.type === "number" ? "number" : "text";
|
|
86
|
+
input.value = getPath(currentConfig, path) ?? "";
|
|
87
|
+
if (fieldSchema.minimum !== undefined) input.min = fieldSchema.minimum;
|
|
88
|
+
if (fieldSchema.type === "integer") input.step = "1";
|
|
89
|
+
field.append(input);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
input.addEventListener("input", () => {
|
|
93
|
+
const value =
|
|
94
|
+
fieldSchema.type === "boolean"
|
|
95
|
+
? input.checked
|
|
96
|
+
: fieldSchema.type === "integer"
|
|
97
|
+
? Number.parseInt(input.value, 10)
|
|
98
|
+
: fieldSchema.type === "number"
|
|
99
|
+
? Number(input.value)
|
|
100
|
+
: input.value;
|
|
101
|
+
setPath(currentConfig, path, value);
|
|
102
|
+
if (path === "numberHeadings.enabled") {
|
|
103
|
+
enforceHeadingDependency(currentConfig);
|
|
104
|
+
syncHeadingDependencyControls();
|
|
105
|
+
}
|
|
106
|
+
updateView();
|
|
107
|
+
});
|
|
108
|
+
return field;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
form.addEventListener("submit", async (event) => {
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
saveButton.disabled = true;
|
|
114
|
+
setStatus("正在保存配置…");
|
|
115
|
+
try {
|
|
116
|
+
const response = await fetch("/api/config", {
|
|
117
|
+
method: "PUT",
|
|
118
|
+
headers: { "Content-Type": "application/json" },
|
|
119
|
+
body: JSON.stringify(currentConfig),
|
|
120
|
+
});
|
|
121
|
+
const result = await response.json();
|
|
122
|
+
if (!response.ok) throw new Error(result.error ?? "保存失败");
|
|
123
|
+
originalConfig = structuredClone(currentConfig);
|
|
124
|
+
updateView();
|
|
125
|
+
setStatus("默认配置已保存");
|
|
126
|
+
} catch (error) {
|
|
127
|
+
setStatus(error.message, true);
|
|
128
|
+
saveButton.disabled = false;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
resetButton.addEventListener("click", () => {
|
|
133
|
+
currentConfig = structuredClone(originalConfig);
|
|
134
|
+
renderForm();
|
|
135
|
+
updateView();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
function updateView() {
|
|
139
|
+
const changes = countChanges(originalConfig, currentConfig);
|
|
140
|
+
saveButton.disabled = changes === 0;
|
|
141
|
+
resetButton.disabled = changes === 0;
|
|
142
|
+
if (changes) setStatus("有尚未保存的修改");
|
|
143
|
+
else if (status.textContent !== "默认配置已保存") setStatus("配置尚未修改");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function countChanges(before, after) {
|
|
147
|
+
return Object.keys(schema.properties).reduce(
|
|
148
|
+
(total, group) =>
|
|
149
|
+
total +
|
|
150
|
+
Object.keys(schema.properties[group].properties).filter(
|
|
151
|
+
(name) => before[group][name] !== after[group][name],
|
|
152
|
+
).length,
|
|
153
|
+
0,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function getPath(source, path) {
|
|
158
|
+
return path.split(".").reduce((value, key) => value?.[key], source);
|
|
159
|
+
}
|
|
160
|
+
function setPath(source, path, value) {
|
|
161
|
+
const [group, name] = path.split(".");
|
|
162
|
+
source[group][name] = value;
|
|
163
|
+
}
|
|
164
|
+
function enforceHeadingDependency(config) {
|
|
165
|
+
if (config.numberHeadings.enabled) config.normalizeHeadings.enabled = true;
|
|
166
|
+
}
|
|
167
|
+
function syncHeadingDependencyControls() {
|
|
168
|
+
const normalizeInput = document.getElementById("normalizeHeadings.enabled");
|
|
169
|
+
normalizeInput.checked = currentConfig.normalizeHeadings.enabled;
|
|
170
|
+
normalizeInput.disabled = currentConfig.numberHeadings.enabled;
|
|
171
|
+
normalizeInput.title = currentConfig.numberHeadings.enabled ? "标题编号开启时无法关闭" : "";
|
|
172
|
+
}
|
|
173
|
+
function setStatus(message, error = false) {
|
|
174
|
+
status.textContent = message;
|
|
175
|
+
status.classList.toggle("error", error);
|
|
176
|
+
}
|
|
177
|
+
function escapeHtml(value) {
|
|
178
|
+
const node = document.createElement("span");
|
|
179
|
+
node.textContent = value;
|
|
180
|
+
return node.innerHTML;
|
|
181
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="description" content="md2docx 默认配置编辑器" />
|
|
7
|
+
<title>md2docx 配置台</title>
|
|
8
|
+
<link rel="stylesheet" href="/app.css" />
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<main class="workspace">
|
|
12
|
+
<nav id="config-index" class="config-index" aria-label="配置索引"></nav>
|
|
13
|
+
<form id="config-form" class="config-sheet" aria-label="默认配置">
|
|
14
|
+
<div class="loading">正在读取配置…</div>
|
|
15
|
+
</form>
|
|
16
|
+
</main>
|
|
17
|
+
|
|
18
|
+
<footer class="action-bar">
|
|
19
|
+
<p id="status" role="status">配置尚未修改</p>
|
|
20
|
+
<div class="actions">
|
|
21
|
+
<button id="reset-button" class="secondary" type="button" disabled>撤销修改</button>
|
|
22
|
+
<button id="save-button" type="submit" form="config-form" disabled>保存默认配置</button>
|
|
23
|
+
</div>
|
|
24
|
+
</footer>
|
|
25
|
+
<script type="module" src="/app.js"></script>
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
package/src/web.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { writeFile } from "fs/promises";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
|
|
4
|
+
import { getConfigOptions } from "./cli";
|
|
5
|
+
import { CONFIG_PATH, CONFIG_SCHEMA_PATH } from "./paths";
|
|
6
|
+
|
|
7
|
+
const HOSTNAME = "127.0.0.1";
|
|
8
|
+
const PORT = 3210;
|
|
9
|
+
const WEB_DIR = resolve(import.meta.dir, "web");
|
|
10
|
+
|
|
11
|
+
interface WebHandlerOptions {
|
|
12
|
+
configPath?: string;
|
|
13
|
+
schemaPath?: string;
|
|
14
|
+
webDir?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createWebHandler(
|
|
18
|
+
options: WebHandlerOptions = {},
|
|
19
|
+
): (request: Request) => Promise<Response> {
|
|
20
|
+
const configPath = options.configPath ?? CONFIG_PATH;
|
|
21
|
+
const schemaPath = options.schemaPath ?? CONFIG_SCHEMA_PATH;
|
|
22
|
+
const webDir = options.webDir ?? WEB_DIR;
|
|
23
|
+
return async (request) => {
|
|
24
|
+
const url = new URL(request.url);
|
|
25
|
+
|
|
26
|
+
if (url.pathname === "/api/config" && request.method === "GET") {
|
|
27
|
+
const [schema, config] = await Promise.all([
|
|
28
|
+
Bun.file(schemaPath).json(),
|
|
29
|
+
Bun.file(configPath).json(),
|
|
30
|
+
]);
|
|
31
|
+
return Response.json({ schema, config });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (url.pathname === "/api/config" && request.method === "PUT") {
|
|
35
|
+
try {
|
|
36
|
+
const schema = await Bun.file(schemaPath).json();
|
|
37
|
+
const config = await request.json();
|
|
38
|
+
enforceConfigDependencies(config);
|
|
39
|
+
validateWebConfig(config, schema);
|
|
40
|
+
const savedConfig = { $schema: "./config.schema.json", ...config };
|
|
41
|
+
await writeFile(configPath, `${JSON.stringify(savedConfig, null, 2)}\n`, "utf-8");
|
|
42
|
+
return Response.json({ ok: true });
|
|
43
|
+
} catch (error) {
|
|
44
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
45
|
+
return Response.json({ error: message }, { status: 400 });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const files: Record<string, { path: string; type: string }> = {
|
|
50
|
+
"/": { path: "index.html", type: "text/html; charset=utf-8" },
|
|
51
|
+
"/app.css": { path: "app.css", type: "text/css; charset=utf-8" },
|
|
52
|
+
"/app.js": { path: "app.js", type: "text/javascript; charset=utf-8" },
|
|
53
|
+
};
|
|
54
|
+
const asset = files[url.pathname];
|
|
55
|
+
if (!asset || request.method !== "GET") return new Response("Not found", { status: 404 });
|
|
56
|
+
return new Response(Bun.file(resolve(webDir, asset.path)), {
|
|
57
|
+
headers: { "Content-Type": asset.type },
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function startWebEditor(): Promise<void> {
|
|
63
|
+
const server = Bun.serve({ hostname: HOSTNAME, port: PORT, fetch: createWebHandler() });
|
|
64
|
+
const url = `http://${server.hostname}:${server.port}`;
|
|
65
|
+
console.log(`配置编辑器已启动:${url}`);
|
|
66
|
+
console.log("按 Ctrl+C 停止服务");
|
|
67
|
+
openBrowser(url);
|
|
68
|
+
await new Promise(() => undefined);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function enforceConfigDependencies(config: unknown): void {
|
|
72
|
+
if (!isRecord(config)) return;
|
|
73
|
+
const numberHeadings = config.numberHeadings;
|
|
74
|
+
const normalizeHeadings = config.normalizeHeadings;
|
|
75
|
+
if (!isRecord(numberHeadings) || !isRecord(normalizeHeadings)) return;
|
|
76
|
+
if (numberHeadings.enabled === true) normalizeHeadings.enabled = true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateWebConfig(config: unknown, schema: unknown): void {
|
|
80
|
+
if (!isRecord(config) || !isRecord(schema)) throw new Error("配置必须是 JSON 对象");
|
|
81
|
+
const properties = schema.properties;
|
|
82
|
+
if (!isRecord(properties)) throw new Error("配置 schema 无效");
|
|
83
|
+
assertKnownKeys(config, properties, "");
|
|
84
|
+
const options = getConfigOptions(schema);
|
|
85
|
+
for (const option of options) {
|
|
86
|
+
const value = getPath(config, option.path);
|
|
87
|
+
if (value === undefined) {
|
|
88
|
+
if (option.required) throw new Error(`缺少配置项:${option.path}`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (option.type === "integer" && !Number.isInteger(value)) {
|
|
92
|
+
throw new Error(`${option.path} 必须是整数`);
|
|
93
|
+
}
|
|
94
|
+
if (option.type === "number" && (typeof value !== "number" || !Number.isFinite(value))) {
|
|
95
|
+
throw new Error(`${option.path} 必须是数字`);
|
|
96
|
+
}
|
|
97
|
+
if (option.type !== "integer" && option.type !== "number" && typeof value !== option.type) {
|
|
98
|
+
throw new Error(`${option.path} 必须是 ${option.type}`);
|
|
99
|
+
}
|
|
100
|
+
if (option.enum && !option.enum.includes(value)) {
|
|
101
|
+
throw new Error(`${option.path} 的值无效`);
|
|
102
|
+
}
|
|
103
|
+
if (typeof value === "number" && option.minimum !== undefined && value < option.minimum) {
|
|
104
|
+
throw new Error(`${option.path} 不能小于 ${option.minimum}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function assertKnownKeys(
|
|
110
|
+
config: Record<string, unknown>,
|
|
111
|
+
properties: Record<string, unknown>,
|
|
112
|
+
prefix: string,
|
|
113
|
+
): void {
|
|
114
|
+
for (const key of Object.keys(config)) {
|
|
115
|
+
if (!(key in properties)) throw new Error(`未知配置项:${prefix}${key}`);
|
|
116
|
+
}
|
|
117
|
+
for (const [key, childSchema] of Object.entries(properties)) {
|
|
118
|
+
if (!isRecord(childSchema) || childSchema.type !== "object") continue;
|
|
119
|
+
const childConfig = config[key];
|
|
120
|
+
if (!isRecord(childConfig)) throw new Error(`${prefix}${key} 必须是对象`);
|
|
121
|
+
const childProperties = childSchema.properties;
|
|
122
|
+
if (!isRecord(childProperties)) throw new Error("配置 schema 无效");
|
|
123
|
+
assertKnownKeys(childConfig, childProperties, `${prefix}${key}.`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getPath(source: Record<string, unknown>, path: string): unknown {
|
|
128
|
+
let current: unknown = source;
|
|
129
|
+
for (const segment of path.split(".")) {
|
|
130
|
+
if (!isRecord(current)) return undefined;
|
|
131
|
+
current = current[segment];
|
|
132
|
+
}
|
|
133
|
+
return current;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
137
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function openBrowser(url: string): void {
|
|
141
|
+
const command =
|
|
142
|
+
process.platform === "win32"
|
|
143
|
+
? ["cmd", "/c", "start", "", url]
|
|
144
|
+
: process.platform === "darwin"
|
|
145
|
+
? ["open", url]
|
|
146
|
+
: ["xdg-open", url];
|
|
147
|
+
Bun.spawn(command, { stdout: "ignore", stderr: "ignore" });
|
|
148
|
+
}
|