@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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/engine/chrome-pdf.d.mts +34 -0
  4. package/engine/chrome-pdf.mjs +344 -0
  5. package/engine/cli.mjs +93 -0
  6. package/engine/commands/_shared.mjs +170 -0
  7. package/engine/commands/deploy.mjs +31 -0
  8. package/engine/commands/dev.mjs +26 -0
  9. package/engine/commands/export.mjs +8 -0
  10. package/engine/commands/init.mjs +24 -0
  11. package/engine/commands/inspect.mjs +35 -0
  12. package/engine/commands/migrate-to-react.mjs +27 -0
  13. package/engine/commands/pdf.mjs +26 -0
  14. package/engine/commands/preview.mjs +26 -0
  15. package/engine/commands/render.mjs +17 -0
  16. package/engine/commands/replace.mjs +41 -0
  17. package/engine/commands/search.mjs +33 -0
  18. package/engine/commands/typecheck.mjs +5 -0
  19. package/engine/commands/validate.mjs +17 -0
  20. package/engine/config.d.mts +40 -0
  21. package/engine/config.mjs +160 -0
  22. package/engine/deploy-sync.mjs +15 -0
  23. package/engine/document-export.mjs +15 -0
  24. package/engine/file-utils.mjs +106 -0
  25. package/engine/fonts.mjs +62 -0
  26. package/engine/init.mjs +90 -0
  27. package/engine/inspection.mjs +348 -0
  28. package/engine/issue-report.mjs +44 -0
  29. package/engine/katex-assets.mjs +45 -0
  30. package/engine/page-block.mjs +30 -0
  31. package/engine/page-renderer.mjs +217 -0
  32. package/engine/pdf-media.mjs +45 -0
  33. package/engine/public-assets.mjs +19 -0
  34. package/engine/react/chapter-css.mjs +53 -0
  35. package/engine/react/comment-endpoint.d.mts +11 -0
  36. package/engine/react/comment-endpoint.mjs +128 -0
  37. package/engine/react/comment-marker.mjs +306 -0
  38. package/engine/react/document-entry.mjs +253 -0
  39. package/engine/react/document-export.mjs +392 -0
  40. package/engine/react/mdx-compile.mjs +295 -0
  41. package/engine/react/measurement-css.mjs +44 -0
  42. package/engine/react/migrate-to-react.mjs +355 -0
  43. package/engine/react/pagination-constants.mjs +3 -0
  44. package/engine/react/pagination.mjs +121 -0
  45. package/engine/react/project-asset-endpoint.d.mts +10 -0
  46. package/engine/react/project-asset-endpoint.mjs +379 -0
  47. package/engine/react/workspace-discovery.mjs +156 -0
  48. package/engine/source-text-tools.mjs +280 -0
  49. package/engine/source-workspace.mjs +76 -0
  50. package/engine/static-server.mjs +493 -0
  51. package/engine/validation.mjs +172 -0
  52. package/index.html +13 -0
  53. package/package.json +86 -0
  54. package/src/openpress/App.tsx +127 -0
  55. package/src/openpress/composerMentions.ts +188 -0
  56. package/src/openpress/core/basePages.tsx +87 -0
  57. package/src/openpress/core/index.tsx +20 -0
  58. package/src/openpress/core/types.ts +71 -0
  59. package/src/openpress/frameScheduler.ts +32 -0
  60. package/src/openpress/indexes.ts +329 -0
  61. package/src/openpress/inspector.ts +282 -0
  62. package/src/openpress/pageRoute.ts +21 -0
  63. package/src/openpress/pagination.ts +845 -0
  64. package/src/openpress/projectIdentity.ts +15 -0
  65. package/src/openpress/projectSources.ts +24 -0
  66. package/src/openpress/projectWorkspace.tsx +919 -0
  67. package/src/openpress/publicPage.tsx +469 -0
  68. package/src/openpress/reactDocumentMetadata.ts +41 -0
  69. package/src/openpress/readerPageRegistry.ts +41 -0
  70. package/src/openpress/readerRuntime.ts +230 -0
  71. package/src/openpress/readerScroll.ts +92 -0
  72. package/src/openpress/readerState.ts +15 -0
  73. package/src/openpress/renderer.tsx +91 -0
  74. package/src/openpress/runtimeMode.ts +22 -0
  75. package/src/openpress/types.ts +112 -0
  76. package/src/openpress/workbench.tsx +1299 -0
  77. package/src/openpress/workbenchPanels.tsx +122 -0
  78. package/src/openpress/workbenchTypes.ts +4 -0
  79. package/src/styles/openpress/app-shell.css +251 -0
  80. package/src/styles/openpress/media-workspace.css +230 -0
  81. package/src/styles/openpress/print-route.css +186 -0
  82. package/src/styles/openpress/project-workspace.css +1318 -0
  83. package/src/styles/openpress/public-viewer.css +983 -0
  84. package/src/styles/openpress/reader-runtime.css +792 -0
  85. package/src/styles/openpress/responsive.css +384 -0
  86. package/src/styles/openpress/workbench-panels.css +558 -0
  87. package/src/styles/openpress/workbench.css +720 -0
  88. package/src/styles/openpress.css +14 -0
  89. package/tsconfig.json +37 -0
  90. package/vite.config.ts +512 -0
@@ -0,0 +1,379 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { loadConfig } from "../config.mjs";
4
+ import { collectSourceTextFiles } from "../source-text-tools.mjs";
5
+ import { insertCommentMarker } from "./comment-marker.mjs";
6
+
7
+ const MAX_PROJECT_ASSET_BODY_BYTES = 64 * 1024;
8
+
9
+ export async function handleProjectAssetRequest(req, res, {
10
+ root = ".",
11
+ timestamp = undefined,
12
+ } = {}) {
13
+ if (req.method !== "POST") {
14
+ writeJson(res, 405, { ok: false, message: "OpenPress project asset endpoint requires POST." });
15
+ return;
16
+ }
17
+
18
+ try {
19
+ const body = await readJsonBody(req);
20
+ const config = await loadConfig(root);
21
+ const action = stringValue(body?.action);
22
+ const kind = stringValue(body?.kind);
23
+ const name = stringValue(body?.name);
24
+
25
+ if (kind !== "media" && kind !== "component") {
26
+ throw new Error("Project asset kind must be `media` or `component`.");
27
+ }
28
+ if (!name) throw new Error("Project asset action requires a name.");
29
+
30
+ if (action === "rename") {
31
+ const result = await renameProjectAsset({
32
+ config,
33
+ kind,
34
+ name,
35
+ nextName: body?.nextName,
36
+ });
37
+ writeJson(res, 200, { ok: true, ...result });
38
+ return;
39
+ }
40
+
41
+ if (action === "delete") {
42
+ const result = await deleteProjectAsset({ config, kind, name });
43
+ const status = result.needsReferenceCleanup ? 409 : 200;
44
+ writeJson(res, status, { ok: !result.needsReferenceCleanup, ...result });
45
+ return;
46
+ }
47
+
48
+ if (action === "comment") {
49
+ const result = await createProjectAssetComment({
50
+ config,
51
+ kind,
52
+ name,
53
+ note: body?.note,
54
+ commentTarget: body?.commentTarget,
55
+ currentSource: body?.currentSource,
56
+ timestamp,
57
+ });
58
+ writeJson(res, 200, { ok: true, ...result });
59
+ return;
60
+ }
61
+
62
+ throw new Error("Project asset action must be `rename`, `delete`, or `comment`.");
63
+ } catch (error) {
64
+ writeJson(res, 400, {
65
+ ok: false,
66
+ message: error instanceof Error ? error.message : String(error),
67
+ });
68
+ }
69
+ }
70
+
71
+ async function renameProjectAsset({ config, kind, name, nextName }) {
72
+ const normalizedCurrentName = normalizeAssetName(kind, name);
73
+ const normalizedNextName = normalizeAssetName(kind, stringValue(nextName), normalizedCurrentName);
74
+ if (!normalizedNextName || normalizedNextName === normalizedCurrentName) {
75
+ throw new Error("Rename requires a different valid name.");
76
+ }
77
+
78
+ const currentPath = resolveAssetPath(config, kind, normalizedCurrentName);
79
+ const nextPath = resolveAssetPath(config, kind, normalizedNextName);
80
+ await assertPathExists(currentPath, `${kind} asset not found: ${normalizedCurrentName}`);
81
+ if (await fileExists(nextPath)) throw new Error(`${kind} asset already exists: ${normalizedNextName}`);
82
+
83
+ await fs.rename(currentPath, nextPath);
84
+ const referenceResult = await replaceProjectAssetReferences({
85
+ config,
86
+ kind,
87
+ from: normalizedCurrentName,
88
+ to: normalizedNextName,
89
+ });
90
+
91
+ return {
92
+ action: "rename",
93
+ kind,
94
+ name: normalizedCurrentName,
95
+ nextName: normalizedNextName,
96
+ referenceCount: referenceResult.referenceCount,
97
+ fileCount: referenceResult.fileCount,
98
+ };
99
+ }
100
+
101
+ async function deleteProjectAsset({ config, kind, name }) {
102
+ const normalizedName = normalizeAssetName(kind, name);
103
+ const references = await findProjectAssetReferences({ config, kind, name: normalizedName });
104
+ if (references.length > 0) {
105
+ return {
106
+ action: "delete",
107
+ kind,
108
+ name: normalizedName,
109
+ needsReferenceCleanup: true,
110
+ referenceCount: references.length,
111
+ references: references.slice(0, 12),
112
+ message: `Cannot delete ${kind} asset while ${references.length} reference(s) still exist.`,
113
+ };
114
+ }
115
+
116
+ const targetPath = resolveAssetPath(config, kind, normalizedName);
117
+ await assertPathExists(targetPath, `${kind} asset not found: ${normalizedName}`);
118
+ await fs.rm(targetPath, { recursive: true, force: true });
119
+
120
+ return {
121
+ action: "delete",
122
+ kind,
123
+ name: normalizedName,
124
+ needsReferenceCleanup: false,
125
+ referenceCount: 0,
126
+ };
127
+ }
128
+
129
+ async function createProjectAssetComment({
130
+ config,
131
+ kind,
132
+ name,
133
+ note,
134
+ commentTarget,
135
+ currentSource,
136
+ timestamp,
137
+ }) {
138
+ const normalizedName = normalizeAssetName(kind, name);
139
+ const noteText = stringValue(note);
140
+ if (!noteText) throw new Error("Project asset comment requires a note.");
141
+
142
+ const target = await resolveCommentTarget({
143
+ config,
144
+ kind,
145
+ name: normalizedName,
146
+ commentTarget: stringValue(commentTarget),
147
+ currentSource,
148
+ });
149
+
150
+ const result = await insertCommentMarker({
151
+ root: config.root,
152
+ path: target.path,
153
+ source: { line: target.line, column: 1 },
154
+ note: `${assetLabel(kind, normalizedName)}:${noteText}`,
155
+ hint: `openpress-project-asset kind=${kind} action=comment target=${target.reason} asset=${normalizedName}`,
156
+ timestamp,
157
+ });
158
+
159
+ return {
160
+ action: "comment",
161
+ kind,
162
+ name: normalizedName,
163
+ comment: {
164
+ id: result.id,
165
+ timestamp: result.timestamp,
166
+ path: result.path,
167
+ line: result.line,
168
+ },
169
+ };
170
+ }
171
+
172
+ async function resolveCommentTarget({ config, kind, name, commentTarget, currentSource }) {
173
+ if (commentTarget === "current-page") {
174
+ const currentPath = stringValue(currentSource?.path);
175
+ if (currentPath) {
176
+ return {
177
+ path: currentPath,
178
+ line: normalizePositiveInteger(currentSource?.line) ?? 1,
179
+ reason: "current-page",
180
+ };
181
+ }
182
+ }
183
+
184
+ const references = await findProjectAssetReferences({ config, kind, name });
185
+ const preferred = references.find((reference) => {
186
+ if (kind === "component") return reference.preview.includes("data-openpress-component");
187
+ return reference.path.includes("/content/") || reference.path.endsWith(".mdx");
188
+ }) ?? references[0];
189
+ if (!preferred) {
190
+ throw new Error(`No editable reference found for ${kind} asset: ${name}`);
191
+ }
192
+ return {
193
+ path: preferred.path,
194
+ line: preferred.line,
195
+ reason: "asset-reference",
196
+ };
197
+ }
198
+
199
+ async function replaceProjectAssetReferences({ config, kind, from, to }) {
200
+ const replacements = replacementPairs(kind, from, to);
201
+ const files = await collectSourceTextFiles(config, { scope: "all" });
202
+ let referenceCount = 0;
203
+ let fileCount = 0;
204
+
205
+ for (const file of files) {
206
+ let text = file.text;
207
+ let changed = false;
208
+ for (const [fromText, toText] of replacements) {
209
+ if (!fromText || fromText === toText || !text.includes(fromText)) continue;
210
+ const count = text.split(fromText).length - 1;
211
+ text = text.split(fromText).join(toText);
212
+ referenceCount += count;
213
+ changed = true;
214
+ }
215
+ if (!changed) continue;
216
+ fileCount += 1;
217
+ await fs.writeFile(file.absolutePath, text, "utf8");
218
+ }
219
+
220
+ return { referenceCount, fileCount };
221
+ }
222
+
223
+ async function findProjectAssetReferences({ config, kind, name }) {
224
+ const tokens = referenceTokens(kind, name);
225
+ const files = await collectSourceTextFiles(config, { scope: "all" });
226
+ const references = [];
227
+
228
+ for (const file of files) {
229
+ const lines = file.text.split(/\r?\n/);
230
+ lines.forEach((line, index) => {
231
+ if (!tokens.some((token) => token && line.includes(token))) return;
232
+ references.push({
233
+ path: file.relativePath,
234
+ line: index + 1,
235
+ preview: line.trim().slice(0, 180),
236
+ });
237
+ });
238
+ }
239
+
240
+ return references;
241
+ }
242
+
243
+ function resolveAssetPath(config, kind, name) {
244
+ const root = kind === "media" ? config.paths.mediaDir : config.paths.componentsDir;
245
+ const target = path.resolve(root, name);
246
+ const resolvedRoot = path.resolve(root);
247
+ if (!target.startsWith(`${resolvedRoot}${path.sep}`) && target !== resolvedRoot) {
248
+ throw new Error(`Project asset path escapes ${kind} directory: ${name}`);
249
+ }
250
+ return target;
251
+ }
252
+
253
+ function normalizeAssetName(kind, value, currentName = "") {
254
+ if (kind === "media") return sanitizeMediaFileName(value, currentName);
255
+ return sanitizeComponentName(value);
256
+ }
257
+
258
+ function sanitizeMediaFileName(value, currentName = "") {
259
+ const rawName = stringValue(value);
260
+ if (!rawName) return "";
261
+ const currentExt = path.extname(currentName);
262
+ const suppliedExt = path.extname(rawName);
263
+ const baseName = path.basename(suppliedExt ? rawName : `${rawName}${currentExt}`).trim();
264
+ if (!baseName) return "";
265
+ const ext = path.extname(baseName);
266
+ const stem = path.basename(baseName, ext)
267
+ .replace(/[\\/:*?"<>|#%{}^~[\]`]/g, "-")
268
+ .replace(/\s+/g, "-")
269
+ .replace(/-+/g, "-")
270
+ .replace(/^-|-$/g, "");
271
+ if (!stem || !ext || !isAllowedMediaFile(`${stem}${ext}`)) return "";
272
+ return `${stem}${ext.toLowerCase()}`;
273
+ }
274
+
275
+ function sanitizeComponentName(value) {
276
+ const raw = stringValue(value).replaceAll("\\", "/").split("/").pop() ?? "";
277
+ const normalized = raw
278
+ .trim()
279
+ .replace(/\s+/g, "-")
280
+ .replace(/[^a-zA-Z0-9_-]/g, "-")
281
+ .replace(/-+/g, "-")
282
+ .replace(/^-|-$/g, "");
283
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(normalized)) return "";
284
+ return normalized;
285
+ }
286
+
287
+ function isAllowedMediaFile(fileName) {
288
+ return /\.(png|jpe?g|gif|svg|webp)$/i.test(fileName);
289
+ }
290
+
291
+ function replacementPairs(kind, from, to) {
292
+ if (kind === "media") {
293
+ return uniquePairs([
294
+ [from, to],
295
+ [encodeURIComponent(from), encodeURIComponent(to)],
296
+ [`@media/${from}`, `@media/${to}`],
297
+ ]);
298
+ }
299
+ return uniquePairs([
300
+ [from, to],
301
+ [`@component/${from}`, `@component/${to}`],
302
+ ]);
303
+ }
304
+
305
+ function referenceTokens(kind, name) {
306
+ if (kind === "media") {
307
+ return uniqueValues([
308
+ name,
309
+ encodeURIComponent(name),
310
+ `@media/${name}`,
311
+ ]);
312
+ }
313
+ return uniqueValues([
314
+ name,
315
+ `@component/${name}`,
316
+ `data-openpress-component="${name}"`,
317
+ `data-openpress-component='${name}'`,
318
+ ]);
319
+ }
320
+
321
+ function uniquePairs(pairs) {
322
+ const seen = new Set();
323
+ return pairs.filter(([from, to]) => {
324
+ const key = `${from}\0${to}`;
325
+ if (seen.has(key)) return false;
326
+ seen.add(key);
327
+ return true;
328
+ });
329
+ }
330
+
331
+ function uniqueValues(values) {
332
+ return Array.from(new Set(values.filter(Boolean)));
333
+ }
334
+
335
+ function assetLabel(kind, name) {
336
+ return kind === "media" ? `Media ${name}` : `Component ${name}`;
337
+ }
338
+
339
+ function stringValue(value) {
340
+ return typeof value === "string" ? value.trim() : "";
341
+ }
342
+
343
+ function normalizePositiveInteger(value) {
344
+ const number = Number(value);
345
+ return Number.isInteger(number) && number > 0 ? number : null;
346
+ }
347
+
348
+ async function assertPathExists(filePath, message) {
349
+ if (!(await fileExists(filePath))) throw new Error(message);
350
+ }
351
+
352
+ async function fileExists(filePath) {
353
+ try {
354
+ await fs.access(filePath);
355
+ return true;
356
+ } catch {
357
+ return false;
358
+ }
359
+ }
360
+
361
+ async function readJsonBody(req) {
362
+ let body = "";
363
+ for await (const chunk of req) {
364
+ body += String(chunk);
365
+ if (Buffer.byteLength(body, "utf8") > MAX_PROJECT_ASSET_BODY_BYTES) {
366
+ throw new Error("Project asset request body is too large.");
367
+ }
368
+ }
369
+ try {
370
+ return JSON.parse(body || "{}");
371
+ } catch {
372
+ throw new Error("Project asset request body must be valid JSON.");
373
+ }
374
+ }
375
+
376
+ function writeJson(res, status, body) {
377
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
378
+ res.end(`${JSON.stringify(body, null, 2)}\n`);
379
+ }
@@ -0,0 +1,156 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const COMPONENT_EXT = ".tsx";
5
+ const CHAPTER_ENTRY = "chapter.tsx";
6
+
7
+ export async function discoverReactWorkspace(root = ".", config = {}) {
8
+ const workspaceRoot = path.resolve(root);
9
+ const documentRoot = config.paths?.documentRoot ?? path.join(workspaceRoot, "document");
10
+ const componentsRoot = config.paths?.componentsDir ?? path.join(documentRoot, "components");
11
+ const chaptersRoot = config.paths?.chaptersDir ?? config.paths?.sourceDir ?? path.join(documentRoot, "chapters");
12
+ const globalComponents = await discoverComponents(componentsRoot, documentRoot, "global");
13
+ const chapters = await discoverChapters(documentRoot, chaptersRoot, globalComponents);
14
+
15
+ return {
16
+ root: workspaceRoot,
17
+ documentRoot,
18
+ globalComponents,
19
+ chapters,
20
+ };
21
+ }
22
+
23
+ async function discoverChapters(documentRoot, chaptersDir, globalComponents) {
24
+ const entries = await readDirectoryEntries(chaptersDir);
25
+ const chapterDirs = entries.filter((entry) => entry.isDirectory()).sort(compareChapterDirectories);
26
+
27
+ const chapters = [];
28
+ for (const entry of chapterDirs) {
29
+ const chapterPath = path.join(chaptersDir, entry.name);
30
+ const chapterEntryPath = path.join(chapterPath, CHAPTER_ENTRY);
31
+ const localComponents = await discoverComponents(path.join(chapterPath, "components"), documentRoot, "chapter");
32
+ const contentFiles = await discoverContentFiles(path.join(chapterPath, "content"), documentRoot);
33
+ const styleFiles = await discoverStyleFiles(path.join(chapterPath, "styles"), documentRoot);
34
+ const chapterEntry = (await fileExists(chapterEntryPath)) ? pathRecord(chapterEntryPath, documentRoot) : null;
35
+
36
+ chapters.push({
37
+ directoryName: entry.name,
38
+ slug: chapterSlugFromDirectory(entry.name),
39
+ absolutePath: chapterPath,
40
+ documentPath: documentRelativePath(chapterPath, documentRoot),
41
+ chapterEntry,
42
+ contentFiles,
43
+ styleFiles,
44
+ localComponents,
45
+ componentScope: createComponentScope(globalComponents, localComponents),
46
+ });
47
+ }
48
+
49
+ return chapters;
50
+ }
51
+
52
+ async function discoverComponents(componentsDir, documentRoot, scope) {
53
+ const entries = await readDirectoryEntries(componentsDir);
54
+ const components = [];
55
+
56
+ for (const entry of entries) {
57
+ if (entry.isFile() && path.extname(entry.name) === COMPONENT_EXT) {
58
+ const absolutePath = path.join(componentsDir, entry.name);
59
+ components.push(componentRecord(path.basename(entry.name, COMPONENT_EXT), absolutePath, documentRoot, scope));
60
+ continue;
61
+ }
62
+
63
+ if (entry.isDirectory()) {
64
+ const indexPath = path.join(componentsDir, entry.name, `index${COMPONENT_EXT}`);
65
+ if (await fileExists(indexPath)) {
66
+ components.push(componentRecord(entry.name, indexPath, documentRoot, scope));
67
+ }
68
+ }
69
+ }
70
+
71
+ return components.sort((a, b) => a.name.localeCompare(b.name) || a.documentPath.localeCompare(b.documentPath));
72
+ }
73
+
74
+ async function discoverContentFiles(contentDir, documentRoot) {
75
+ return discoverFilesByExtension(contentDir, documentRoot, ".mdx");
76
+ }
77
+
78
+ async function discoverStyleFiles(stylesDir, documentRoot) {
79
+ return discoverFilesByExtension(stylesDir, documentRoot, ".css");
80
+ }
81
+
82
+ async function discoverFilesByExtension(directory, documentRoot, extension) {
83
+ const entries = await readDirectoryEntries(directory);
84
+ return entries
85
+ .filter((entry) => entry.isFile() && path.extname(entry.name) === extension)
86
+ .sort((a, b) => a.name.localeCompare(b.name))
87
+ .map((entry) => pathRecord(path.join(directory, entry.name), documentRoot));
88
+ }
89
+
90
+ function createComponentScope(globalComponents, localComponents) {
91
+ const scope = {};
92
+ for (const component of globalComponents) {
93
+ scope[component.name] = component;
94
+ }
95
+ for (const component of localComponents) {
96
+ scope[component.name] = component;
97
+ }
98
+ return scope;
99
+ }
100
+
101
+ function componentRecord(name, absolutePath, documentRoot, scope) {
102
+ return {
103
+ name,
104
+ scope,
105
+ ...pathRecord(absolutePath, documentRoot),
106
+ };
107
+ }
108
+
109
+ function pathRecord(absolutePath, documentRoot) {
110
+ return {
111
+ absolutePath,
112
+ documentPath: documentRelativePath(absolutePath, documentRoot),
113
+ };
114
+ }
115
+
116
+ function documentRelativePath(absolutePath, documentRoot) {
117
+ return path.relative(documentRoot, absolutePath).split(path.sep).join("/");
118
+ }
119
+
120
+ function compareChapterDirectories(a, b) {
121
+ const left = chapterSortKey(a.name);
122
+ const right = chapterSortKey(b.name);
123
+ if (left.order !== right.order) return left.order - right.order;
124
+ return left.name.localeCompare(right.name);
125
+ }
126
+
127
+ function chapterSortKey(directoryName) {
128
+ const match = directoryName.match(/^(\d+)[-_]?(.*)$/);
129
+ if (!match) {
130
+ return { order: Number.POSITIVE_INFINITY, name: directoryName };
131
+ }
132
+ return { order: Number.parseInt(match[1], 10), name: match[2] || directoryName };
133
+ }
134
+
135
+ function chapterSlugFromDirectory(directoryName) {
136
+ return directoryName.replace(/^\d+[-_]?/, "");
137
+ }
138
+
139
+ async function readDirectoryEntries(directory) {
140
+ try {
141
+ return await fs.readdir(directory, { withFileTypes: true });
142
+ } catch (error) {
143
+ if (error?.code === "ENOENT") return [];
144
+ throw error;
145
+ }
146
+ }
147
+
148
+ async function fileExists(filePath) {
149
+ try {
150
+ const stat = await fs.stat(filePath);
151
+ return stat.isFile();
152
+ } catch (error) {
153
+ if (error?.code === "ENOENT") return false;
154
+ throw error;
155
+ }
156
+ }