@open-press/core 0.3.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 +36 -0
- package/engine/chrome-pdf.d.mts +34 -0
- package/engine/chrome-pdf.mjs +344 -0
- package/engine/cli.mjs +93 -0
- package/engine/commands/_shared.mjs +170 -0
- package/engine/commands/deploy.mjs +31 -0
- package/engine/commands/dev.mjs +26 -0
- package/engine/commands/export.mjs +8 -0
- package/engine/commands/init.mjs +24 -0
- package/engine/commands/inspect.mjs +35 -0
- package/engine/commands/migrate-to-react.mjs +27 -0
- package/engine/commands/pdf.mjs +26 -0
- package/engine/commands/preview.mjs +26 -0
- package/engine/commands/render.mjs +17 -0
- package/engine/commands/replace.mjs +41 -0
- package/engine/commands/search.mjs +33 -0
- package/engine/commands/typecheck.mjs +5 -0
- package/engine/commands/validate.mjs +17 -0
- package/engine/config.d.mts +40 -0
- package/engine/config.mjs +160 -0
- package/engine/deploy-sync.mjs +15 -0
- package/engine/document-export.mjs +15 -0
- package/engine/file-utils.mjs +106 -0
- package/engine/fonts.mjs +62 -0
- package/engine/init.mjs +90 -0
- package/engine/inspection.mjs +348 -0
- package/engine/issue-report.mjs +44 -0
- package/engine/katex-assets.mjs +45 -0
- package/engine/page-block.mjs +30 -0
- package/engine/page-renderer.mjs +217 -0
- package/engine/pdf-media.mjs +45 -0
- package/engine/public-assets.mjs +19 -0
- package/engine/react/chapter-css.mjs +53 -0
- package/engine/react/comment-endpoint.d.mts +11 -0
- package/engine/react/comment-endpoint.mjs +128 -0
- package/engine/react/comment-marker.mjs +306 -0
- package/engine/react/document-entry.mjs +253 -0
- package/engine/react/document-export.mjs +392 -0
- package/engine/react/mdx-compile.mjs +295 -0
- package/engine/react/measurement-css.mjs +44 -0
- package/engine/react/migrate-to-react.mjs +355 -0
- package/engine/react/pagination-constants.mjs +3 -0
- package/engine/react/pagination.mjs +121 -0
- package/engine/react/project-asset-endpoint.d.mts +10 -0
- package/engine/react/project-asset-endpoint.mjs +379 -0
- package/engine/react/workspace-discovery.mjs +156 -0
- package/engine/source-text-tools.mjs +280 -0
- package/engine/source-workspace.mjs +76 -0
- package/engine/static-server.mjs +493 -0
- package/engine/validation.mjs +172 -0
- package/index.html +13 -0
- package/package.json +86 -0
- package/src/openpress/App.tsx +127 -0
- package/src/openpress/composerMentions.ts +188 -0
- package/src/openpress/core/basePages.tsx +87 -0
- package/src/openpress/core/index.tsx +20 -0
- package/src/openpress/core/types.ts +71 -0
- package/src/openpress/frameScheduler.ts +32 -0
- package/src/openpress/indexes.ts +329 -0
- package/src/openpress/inspector.ts +282 -0
- package/src/openpress/pageRoute.ts +21 -0
- package/src/openpress/pagination.ts +845 -0
- package/src/openpress/projectIdentity.ts +15 -0
- package/src/openpress/projectSources.ts +24 -0
- package/src/openpress/projectWorkspace.tsx +919 -0
- package/src/openpress/publicPage.tsx +469 -0
- package/src/openpress/reactDocumentMetadata.ts +41 -0
- package/src/openpress/readerPageRegistry.ts +41 -0
- package/src/openpress/readerRuntime.ts +230 -0
- package/src/openpress/readerScroll.ts +92 -0
- package/src/openpress/readerState.ts +15 -0
- package/src/openpress/renderer.tsx +91 -0
- package/src/openpress/runtimeMode.ts +22 -0
- package/src/openpress/types.ts +112 -0
- package/src/openpress/workbench.tsx +1299 -0
- package/src/openpress/workbenchPanels.tsx +122 -0
- package/src/openpress/workbenchTypes.ts +4 -0
- package/src/styles/openpress/app-shell.css +251 -0
- package/src/styles/openpress/media-workspace.css +230 -0
- package/src/styles/openpress/print-route.css +186 -0
- package/src/styles/openpress/project-workspace.css +1318 -0
- package/src/styles/openpress/public-viewer.css +983 -0
- package/src/styles/openpress/reader-runtime.css +792 -0
- package/src/styles/openpress/responsive.css +384 -0
- package/src/styles/openpress/workbench-panels.css +558 -0
- package/src/styles/openpress/workbench.css +720 -0
- package/src/styles/openpress.css +14 -0
- package/tsconfig.json +37 -0
- package/vite.config.ts +512 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { loadConfig } from "../config.mjs";
|
|
5
|
+
import { collectSourceTextFiles } from "../source-text-tools.mjs";
|
|
6
|
+
|
|
7
|
+
const EDITABLE_COMMENT_SOURCE_PATTERNS = [
|
|
8
|
+
/^document\/index\.tsx$/,
|
|
9
|
+
/^document\/chapters\/[^/]+\/content\/[^/]+\.mdx$/,
|
|
10
|
+
/^document\/chapters\/[^/]+\/chapter\.tsx$/,
|
|
11
|
+
/^document\/chapters\/[^/]+\/components\/.+\.tsx$/,
|
|
12
|
+
/^document\/components\/.+\.tsx$/,
|
|
13
|
+
];
|
|
14
|
+
const COMMENT_MARKER_RE = /\{\/\*\s*@openpress-comment\b(?<attrs>[^*]*)\*\/\}/g;
|
|
15
|
+
const COMMENT_LINE_RE = /^\s*\{\/\*\s*@openpress-comment\b[^*]*\*\/\}\s*$/;
|
|
16
|
+
|
|
17
|
+
export async function insertCommentMarker({
|
|
18
|
+
root = ".",
|
|
19
|
+
path: sourcePath,
|
|
20
|
+
source,
|
|
21
|
+
note,
|
|
22
|
+
hint,
|
|
23
|
+
id = createCommentId(),
|
|
24
|
+
timestamp = new Date().toISOString(),
|
|
25
|
+
} = {}) {
|
|
26
|
+
const workspaceRoot = path.resolve(root);
|
|
27
|
+
const relativePath = normalizeEditableSourcePath(sourcePath);
|
|
28
|
+
assertEditableCommentPath(relativePath);
|
|
29
|
+
const absolutePath = path.resolve(workspaceRoot, relativePath);
|
|
30
|
+
if (!absolutePath.startsWith(`${workspaceRoot}${path.sep}`)) {
|
|
31
|
+
throw new Error(`OpenPress comment target path escapes workspace: ${sourcePath}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const noteText = normalizedNote(note);
|
|
35
|
+
const marker = createCommentMarker({ id, timestamp, note: noteText, hint });
|
|
36
|
+
const line = normalizeLineNumber(source?.line);
|
|
37
|
+
const text = await fs.readFile(absolutePath, "utf8");
|
|
38
|
+
const nextText = insertLineBefore(text, line, marker);
|
|
39
|
+
await fs.writeFile(absolutePath, nextText, "utf8");
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
id,
|
|
43
|
+
timestamp,
|
|
44
|
+
marker,
|
|
45
|
+
path: relativePath,
|
|
46
|
+
absolutePath,
|
|
47
|
+
line,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createCommentMarker({ id = createCommentId(), timestamp = new Date().toISOString(), note, hint } = {}) {
|
|
52
|
+
const payload = { note: normalizedNote(note), ...(typeof hint === "string" && hint.trim() ? { hint: hint.trim() } : {}) };
|
|
53
|
+
return `{/* @openpress-comment id="${escapeMarkerAttribute(id)}" ts="${escapeMarkerAttribute(timestamp)}" text="${encodeCommentPayload(payload)}" */}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function decodeCommentMarkerText(marker) {
|
|
57
|
+
const match = String(marker ?? "").match(/\btext="([^"]+)"/);
|
|
58
|
+
if (!match) return null;
|
|
59
|
+
return JSON.parse(Buffer.from(match[1], "base64url").toString("utf8"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function listCommentMarkers({ root = "." } = {}) {
|
|
63
|
+
const config = await loadConfig(root);
|
|
64
|
+
const files = await collectSourceTextFiles(config, { scope: "all" });
|
|
65
|
+
const comments = [];
|
|
66
|
+
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
if (!isEditableCommentPath(file.path ?? file.relativePath)) continue;
|
|
69
|
+
comments.push(...extractCommentMarkers(file));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
comments.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.id.localeCompare(b.id));
|
|
73
|
+
return comments;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function clearCommentMarkers({ root = ".", id = null, all = false } = {}) {
|
|
77
|
+
if (!all && !(typeof id === "string" && id.trim())) {
|
|
78
|
+
throw new Error("OpenPress comment clear requires an `id` or `all: true`.");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const config = await loadConfig(root);
|
|
82
|
+
const files = await collectSourceTextFiles(config, { scope: "all" });
|
|
83
|
+
const removed = [];
|
|
84
|
+
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
if (!isEditableCommentPath(file.path ?? file.relativePath)) continue;
|
|
87
|
+
const next = removeCommentMarkerLines(file.text, { id, all });
|
|
88
|
+
if (next.removed.length === 0) continue;
|
|
89
|
+
await fs.writeFile(file.absolutePath, next.text, "utf8");
|
|
90
|
+
for (const marker of next.removed) {
|
|
91
|
+
removed.push({
|
|
92
|
+
...marker,
|
|
93
|
+
path: file.path ?? file.relativePath,
|
|
94
|
+
absolutePath: file.absolutePath,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
removedCount: removed.length,
|
|
101
|
+
comments: removed,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function updateCommentMarker({
|
|
106
|
+
root = ".",
|
|
107
|
+
id,
|
|
108
|
+
note,
|
|
109
|
+
hint,
|
|
110
|
+
timestamp = new Date().toISOString(),
|
|
111
|
+
} = {}) {
|
|
112
|
+
if (!(typeof id === "string" && id.trim())) {
|
|
113
|
+
throw new Error("OpenPress comment update requires an `id`.");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const config = await loadConfig(root);
|
|
117
|
+
const files = await collectSourceTextFiles(config, { scope: "all" });
|
|
118
|
+
const noteText = normalizedNote(note);
|
|
119
|
+
|
|
120
|
+
for (const file of files) {
|
|
121
|
+
if (!isEditableCommentPath(file.path ?? file.relativePath)) continue;
|
|
122
|
+
const next = replaceCommentMarkerLine(file.text, { id, note: noteText, hint, timestamp });
|
|
123
|
+
if (!next.comment) continue;
|
|
124
|
+
await fs.writeFile(file.absolutePath, next.text, "utf8");
|
|
125
|
+
return {
|
|
126
|
+
...next.comment,
|
|
127
|
+
path: file.path ?? file.relativePath,
|
|
128
|
+
absolutePath: file.absolutePath,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
throw new Error(`OpenPress comment marker not found: ${id}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function assertEditableCommentPath(relativePath) {
|
|
136
|
+
if (!isEditableCommentPath(relativePath)) {
|
|
137
|
+
throw new Error(`OpenPress comment target is not an editable OpenPress document source: ${relativePath}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function isEditableCommentPath(relativePath) {
|
|
142
|
+
return EDITABLE_COMMENT_SOURCE_PATTERNS.some((pattern) => pattern.test(relativePath));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function normalizeEditableSourcePath(value) {
|
|
146
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
147
|
+
throw new Error("OpenPress comment target requires a source path.");
|
|
148
|
+
}
|
|
149
|
+
const normalized = value.trim().replaceAll("\\", "/").replace(/^\.\//, "");
|
|
150
|
+
if (path.posix.isAbsolute(normalized) || normalized.includes("\0") || normalized === "." || normalized.startsWith("../")) {
|
|
151
|
+
throw new Error(`OpenPress comment target path is invalid: ${value}`);
|
|
152
|
+
}
|
|
153
|
+
return path.posix.normalize(normalized);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizeLineNumber(value) {
|
|
157
|
+
const line = Number(value);
|
|
158
|
+
if (!Number.isInteger(line) || line < 1) {
|
|
159
|
+
throw new Error("OpenPress comment target requires a 1-based source line.");
|
|
160
|
+
}
|
|
161
|
+
return line;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function normalizedNote(value) {
|
|
165
|
+
const note = typeof value === "string" ? value.trim() : "";
|
|
166
|
+
if (!note) throw new Error("OpenPress comment note must not be empty.");
|
|
167
|
+
return note;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function insertLineBefore(text, line, marker) {
|
|
171
|
+
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
|
172
|
+
const hasTrailingNewline = /\r?\n$/.test(text);
|
|
173
|
+
const lines = text.split(/\r?\n/);
|
|
174
|
+
if (hasTrailingNewline) lines.pop();
|
|
175
|
+
const index = Math.min(Math.max(line - 1, 0), lines.length);
|
|
176
|
+
lines.splice(index, 0, marker);
|
|
177
|
+
return `${lines.join(newline)}${hasTrailingNewline ? newline : ""}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function encodeCommentPayload(payload) {
|
|
181
|
+
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function extractCommentMarkers(file) {
|
|
185
|
+
const comments = [];
|
|
186
|
+
const text = String(file.text ?? "");
|
|
187
|
+
const lineStarts = lineStartOffsets(text);
|
|
188
|
+
for (const match of text.matchAll(COMMENT_MARKER_RE)) {
|
|
189
|
+
const marker = match[0];
|
|
190
|
+
const attrs = parseMarkerAttributes(match.groups?.attrs ?? "");
|
|
191
|
+
const payload = decodeCommentMarkerText(marker) ?? {};
|
|
192
|
+
const line = lineNumberForOffset(lineStarts, match.index ?? 0);
|
|
193
|
+
comments.push({
|
|
194
|
+
id: attrs.id ?? "",
|
|
195
|
+
timestamp: attrs.ts,
|
|
196
|
+
path: file.path ?? file.relativePath,
|
|
197
|
+
absolutePath: file.absolutePath,
|
|
198
|
+
line,
|
|
199
|
+
marker,
|
|
200
|
+
note: typeof payload.note === "string" ? payload.note : "",
|
|
201
|
+
hint: typeof payload.hint === "string" ? payload.hint : undefined,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return comments;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function removeCommentMarkerLines(text, { id, all }) {
|
|
208
|
+
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
|
209
|
+
const hasTrailingNewline = /\r?\n$/.test(text);
|
|
210
|
+
const lines = text.split(/\r?\n/);
|
|
211
|
+
if (hasTrailingNewline) lines.pop();
|
|
212
|
+
const kept = [];
|
|
213
|
+
const removed = [];
|
|
214
|
+
|
|
215
|
+
for (const [index, line] of lines.entries()) {
|
|
216
|
+
if (!COMMENT_LINE_RE.test(line)) {
|
|
217
|
+
kept.push(line);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const attrs = parseMarkerAttributes(line);
|
|
221
|
+
if (all || attrs.id === id) {
|
|
222
|
+
removed.push({ id: attrs.id ?? "", timestamp: attrs.ts, line: index + 1, marker: line.trim() });
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
kept.push(line);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
text: `${kept.join(newline)}${hasTrailingNewline ? newline : ""}`,
|
|
230
|
+
removed,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function replaceCommentMarkerLine(text, { id, note, hint, timestamp }) {
|
|
235
|
+
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
|
236
|
+
const hasTrailingNewline = /\r?\n$/.test(text);
|
|
237
|
+
const lines = text.split(/\r?\n/);
|
|
238
|
+
if (hasTrailingNewline) lines.pop();
|
|
239
|
+
|
|
240
|
+
for (const [index, line] of lines.entries()) {
|
|
241
|
+
if (!COMMENT_LINE_RE.test(line)) continue;
|
|
242
|
+
const attrs = parseMarkerAttributes(line);
|
|
243
|
+
if (attrs.id !== id) continue;
|
|
244
|
+
const marker = createCommentMarker({ id, timestamp, note, hint });
|
|
245
|
+
lines[index] = `${line.match(/^\s*/)?.[0] ?? ""}${marker}`;
|
|
246
|
+
return {
|
|
247
|
+
text: `${lines.join(newline)}${hasTrailingNewline ? newline : ""}`,
|
|
248
|
+
comment: {
|
|
249
|
+
id,
|
|
250
|
+
timestamp,
|
|
251
|
+
line: index + 1,
|
|
252
|
+
marker,
|
|
253
|
+
note,
|
|
254
|
+
hint: typeof hint === "string" && hint.trim() ? hint.trim() : undefined,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { text, comment: null };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function parseMarkerAttributes(value) {
|
|
263
|
+
const attrs = {};
|
|
264
|
+
for (const match of String(value ?? "").matchAll(/\b([A-Za-z_:][-A-Za-z0-9_:.]*)="([^"]*)"/g)) {
|
|
265
|
+
attrs[match[1]] = unescapeMarkerAttribute(match[2]);
|
|
266
|
+
}
|
|
267
|
+
return attrs;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function lineStartOffsets(text) {
|
|
271
|
+
const starts = [0];
|
|
272
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
273
|
+
if (text[index] === "\n") starts.push(index + 1);
|
|
274
|
+
}
|
|
275
|
+
return starts;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function lineNumberForOffset(starts, offset) {
|
|
279
|
+
let line = 1;
|
|
280
|
+
for (const [index, start] of starts.entries()) {
|
|
281
|
+
if (start > offset) break;
|
|
282
|
+
line = index + 1;
|
|
283
|
+
}
|
|
284
|
+
return line;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function createCommentId() {
|
|
288
|
+
return `c-${crypto.randomBytes(4).toString("hex")}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function escapeMarkerAttribute(value) {
|
|
292
|
+
return String(value ?? "").replace(/["&<>]/g, (char) => ({
|
|
293
|
+
"\"": """,
|
|
294
|
+
"&": "&",
|
|
295
|
+
"<": "<",
|
|
296
|
+
">": ">",
|
|
297
|
+
}[char]));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function unescapeMarkerAttribute(value) {
|
|
301
|
+
return String(value ?? "")
|
|
302
|
+
.replace(/"/g, "\"")
|
|
303
|
+
.replace(/&/g, "&")
|
|
304
|
+
.replace(/</g, "<")
|
|
305
|
+
.replace(/>/g, ">");
|
|
306
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import react from "@vitejs/plugin-react";
|
|
6
|
+
import ts from "typescript";
|
|
7
|
+
import { createServer as createViteServer } from "vite";
|
|
8
|
+
import { normalizeConfig } from "../config.mjs";
|
|
9
|
+
|
|
10
|
+
const ENGINE_REACT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const FRAMEWORK_ROOT = path.resolve(ENGINE_REACT_DIR, "..", "..");
|
|
12
|
+
const CORE_ENTRY = path.join(FRAMEWORK_ROOT, "src", "openpress", "core", "index.tsx");
|
|
13
|
+
const REACT_PACKAGE_ROOT = path.join(FRAMEWORK_ROOT, "node_modules", "react");
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const REACT_EXPORT_NAMES = Object.keys(require("react")).filter((name) => /^[A-Za-z_$][\w$]*$/.test(name));
|
|
16
|
+
|
|
17
|
+
export async function loadReactDocumentEntry(root = ".") {
|
|
18
|
+
const workspaceRoot = path.resolve(root);
|
|
19
|
+
const entryPath = path.join(workspaceRoot, "document", "index.tsx");
|
|
20
|
+
if (!(await fileExists(entryPath))) return null;
|
|
21
|
+
|
|
22
|
+
const source = await fs.readFile(entryPath, "utf8");
|
|
23
|
+
assertNoObviousTopLevelSideEffects(source, entryPath);
|
|
24
|
+
|
|
25
|
+
const server = await createReactSsrServer(workspaceRoot);
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const mod = await server.ssrLoadModule(entryPath);
|
|
29
|
+
const config = normalizeReactDocumentConfig(workspaceRoot, entryPath, mod.config);
|
|
30
|
+
return {
|
|
31
|
+
entryPath,
|
|
32
|
+
config,
|
|
33
|
+
shell: {
|
|
34
|
+
cover: mod.cover ?? null,
|
|
35
|
+
toc: mod.toc ?? null,
|
|
36
|
+
backCover: mod.backCover ?? null,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
} finally {
|
|
40
|
+
await server.close();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function createReactSsrServer(workspaceRoot = ".") {
|
|
45
|
+
const resolvedWorkspaceRoot = path.resolve(workspaceRoot);
|
|
46
|
+
return createViteServer({
|
|
47
|
+
configFile: false,
|
|
48
|
+
root: FRAMEWORK_ROOT,
|
|
49
|
+
appType: "custom",
|
|
50
|
+
logLevel: "silent",
|
|
51
|
+
plugins: [reactRuntimePlugin(), react()],
|
|
52
|
+
resolve: {
|
|
53
|
+
alias: [
|
|
54
|
+
{ find: "@openpress/core", replacement: CORE_ENTRY },
|
|
55
|
+
{ find: "@/components", replacement: path.join(resolvedWorkspaceRoot, "document", "components") },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
server: {
|
|
59
|
+
middlewareMode: true,
|
|
60
|
+
fs: {
|
|
61
|
+
allow: [FRAMEWORK_ROOT, resolvedWorkspaceRoot],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeReactDocumentConfig(workspaceRoot, entryPath, config) {
|
|
68
|
+
if (config != null && (typeof config !== "object" || Array.isArray(config))) {
|
|
69
|
+
throw new Error("OpenPress React document entry `config` export must be an object when provided.");
|
|
70
|
+
}
|
|
71
|
+
const rawConfig = config ?? {};
|
|
72
|
+
const paths = rawConfig.paths ?? {};
|
|
73
|
+
return normalizeConfig(workspaceRoot, {
|
|
74
|
+
...rawConfig,
|
|
75
|
+
documentDir: rawConfig.documentDir ?? paths.documentDir ?? "document",
|
|
76
|
+
sourceDir: rawConfig.sourceDir ?? paths.chaptersDir ?? paths.sourceDir ?? "chapters",
|
|
77
|
+
componentsDir: rawConfig.componentsDir ?? paths.componentsDir ?? "components",
|
|
78
|
+
mediaDir: rawConfig.mediaDir ?? paths.mediaDir ?? "media",
|
|
79
|
+
themeDir: rawConfig.themeDir ?? paths.themeDir ?? "theme",
|
|
80
|
+
designDoc: rawConfig.designDoc ?? paths.designDoc ?? "design.md",
|
|
81
|
+
}, entryPath);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function assertNoObviousTopLevelSideEffects(source, entryPath) {
|
|
85
|
+
const sourceFile = ts.createSourceFile(entryPath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
86
|
+
for (const statement of sourceFile.statements) {
|
|
87
|
+
if (ts.isImportDeclaration(statement)) {
|
|
88
|
+
assertPureImport(statement, entryPath);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (ts.isExportDeclaration(statement) && statement.isTypeOnly) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (ts.isVariableStatement(statement)) {
|
|
101
|
+
assertExportedConstStatement(statement, entryPath);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (ts.isExpressionStatement(statement)) {
|
|
106
|
+
assertPureTopLevelInitializer(statement.expression, entryPath);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
throw new Error(`OpenPress React document entry has unsupported top-level code in ${entryPath}: ${statementKindName(statement)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function assertPureImport(statement, entryPath) {
|
|
114
|
+
if (!statement.importClause) {
|
|
115
|
+
throw new Error(`OpenPress React document entry has an unsupported side-effect import in ${entryPath}`);
|
|
116
|
+
}
|
|
117
|
+
const moduleName = stringLiteralText(statement.moduleSpecifier);
|
|
118
|
+
if (!statement.importClause.isTypeOnly && isFileSystemModule(moduleName)) {
|
|
119
|
+
throw new Error(`OpenPress React document entry imports filesystem APIs at top level in ${entryPath}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function assertExportedConstStatement(statement, entryPath) {
|
|
124
|
+
if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword)) {
|
|
125
|
+
throw new Error(`OpenPress React document entry only allows exported const declarations at top level in ${entryPath}`);
|
|
126
|
+
}
|
|
127
|
+
if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) {
|
|
128
|
+
throw new Error(`OpenPress React document entry only allows exported const declarations at top level in ${entryPath}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
132
|
+
if (declaration.initializer) assertPureTopLevelInitializer(declaration.initializer, entryPath);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function assertPureTopLevelInitializer(node, entryPath) {
|
|
137
|
+
visitNode(node, (child) => {
|
|
138
|
+
if (ts.isAwaitExpression(child)) {
|
|
139
|
+
throw new Error(`OpenPress React document entry has an unsupported top-level side effect: await in ${entryPath}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (ts.isCallExpression(child)) {
|
|
143
|
+
const callee = skipExpressionWrappers(child.expression);
|
|
144
|
+
if (ts.isIdentifier(callee) && callee.text === "fetch") {
|
|
145
|
+
throw new Error(`OpenPress React document entry has an unsupported top-level side effect: fetch(...) in ${entryPath}`);
|
|
146
|
+
}
|
|
147
|
+
if (ts.isPropertyAccessExpression(callee) && isIdentifierText(callee.expression, "console")) {
|
|
148
|
+
throw new Error(`OpenPress React document entry has an unsupported top-level side effect: console.${callee.name.text}(...) in ${entryPath}`);
|
|
149
|
+
}
|
|
150
|
+
if (ts.isPropertyAccessExpression(callee) && isIdentifierText(callee.expression, "fs")) {
|
|
151
|
+
throw new Error(`OpenPress React document entry has an unsupported top-level side effect: fs.${callee.name.text}(...) in ${entryPath}`);
|
|
152
|
+
}
|
|
153
|
+
throw new Error(`OpenPress React document entry cannot execute top-level function calls in exported config or shell JSX in ${entryPath}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (ts.isPropertyAccessExpression(child) && isProcessEnvAccess(child)) {
|
|
157
|
+
throw new Error(`OpenPress React document entry cannot read process.env in exported config or shell JSX in ${entryPath}`);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function visitNode(node, visitor) {
|
|
163
|
+
visitor(node);
|
|
164
|
+
ts.forEachChild(node, (child) => visitNode(child, visitor));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function skipExpressionWrappers(node) {
|
|
168
|
+
let current = node;
|
|
169
|
+
while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) {
|
|
170
|
+
current = current.expression;
|
|
171
|
+
}
|
|
172
|
+
return current;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isProcessEnvAccess(node) {
|
|
176
|
+
return node.name.text === "env" && isIdentifierText(skipExpressionWrappers(node.expression), "process");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isIdentifierText(node, text) {
|
|
180
|
+
return ts.isIdentifier(node) && node.text === text;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function hasModifier(node, kind) {
|
|
184
|
+
return ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === kind);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function statementKindName(node) {
|
|
188
|
+
return ts.SyntaxKind[node.kind] ?? String(node.kind);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function stringLiteralText(node) {
|
|
192
|
+
return ts.isStringLiteral(node) ? node.text : "";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isFileSystemModule(moduleName) {
|
|
196
|
+
return moduleName === "fs" || moduleName === "node:fs" || moduleName === "fs/promises" || moduleName === "node:fs/promises";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function fileExists(filePath) {
|
|
200
|
+
try {
|
|
201
|
+
const stat = await fs.stat(filePath);
|
|
202
|
+
return stat.isFile();
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (error?.code === "ENOENT") return false;
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function reactRuntimePlugin() {
|
|
210
|
+
const modules = {
|
|
211
|
+
react: "\0openpress-react",
|
|
212
|
+
"react/jsx-runtime": "\0openpress-react-jsx-runtime",
|
|
213
|
+
"react/jsx-dev-runtime": "\0openpress-react-jsx-dev-runtime",
|
|
214
|
+
};
|
|
215
|
+
return {
|
|
216
|
+
name: "openpress-react-runtime",
|
|
217
|
+
enforce: "pre",
|
|
218
|
+
resolveId(id) {
|
|
219
|
+
return modules[id] ?? null;
|
|
220
|
+
},
|
|
221
|
+
load(id) {
|
|
222
|
+
if (id === modules.react) return reactModuleShim();
|
|
223
|
+
if (id === modules["react/jsx-runtime"]) {
|
|
224
|
+
return runtimeModuleShim(path.join(REACT_PACKAGE_ROOT, "jsx-runtime.js"), ["Fragment", "jsx", "jsxs"]);
|
|
225
|
+
}
|
|
226
|
+
if (id === modules["react/jsx-dev-runtime"]) {
|
|
227
|
+
return runtimeModuleShim(path.join(REACT_PACKAGE_ROOT, "jsx-dev-runtime.js"), ["Fragment", "jsxDEV"]);
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function runtimeModuleShim(modulePath, names) {
|
|
235
|
+
const exports = names.map((name) => `export const ${name} = runtime.${name};`).join("\n");
|
|
236
|
+
return `import { createRequire } from "node:module";
|
|
237
|
+
const require = createRequire(${JSON.stringify(import.meta.url)});
|
|
238
|
+
const runtime = require(${JSON.stringify(modulePath)});
|
|
239
|
+
${exports}
|
|
240
|
+
export default runtime;
|
|
241
|
+
`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function reactModuleShim() {
|
|
245
|
+
const reactPath = require.resolve("react");
|
|
246
|
+
const exports = REACT_EXPORT_NAMES.map((name) => `export const ${name} = React.${name};`).join("\n");
|
|
247
|
+
return `import { createRequire } from "node:module";
|
|
248
|
+
const require = createRequire(${JSON.stringify(import.meta.url)});
|
|
249
|
+
const React = require(${JSON.stringify(reactPath)});
|
|
250
|
+
${exports}
|
|
251
|
+
export default React;
|
|
252
|
+
`;
|
|
253
|
+
}
|