@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,217 @@
|
|
|
1
|
+
const TOC_ENTRIES_PER_PAGE = 24;
|
|
2
|
+
|
|
3
|
+
function renderPageShell(sectionClass, bodyHtml, attrs = "", { kind, footer = true } = {}) {
|
|
4
|
+
const className = footer === false ? addClass(sectionClass, "no-footer") : sectionClass;
|
|
5
|
+
const attrsPart = pageAttrs(attrs, { kind, footer });
|
|
6
|
+
const footerHtml = footer === false ? "" : `
|
|
7
|
+
<footer class="page-footer" aria-hidden="true"></footer>`;
|
|
8
|
+
return `<section class="${className}"${attrsPart}>
|
|
9
|
+
<div class="page-frame">
|
|
10
|
+
<header class="page-header" aria-hidden="true"></header>
|
|
11
|
+
<main class="page-body">
|
|
12
|
+
${bodyHtml.trim()}
|
|
13
|
+
</main>${footerHtml}
|
|
14
|
+
</div>
|
|
15
|
+
</section>`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function renderToc({ title, items, className } = {}) {
|
|
19
|
+
const headingText = typeof title === "string" && title.trim() ? title.trim() : "Contents";
|
|
20
|
+
const tocItems = Array.isArray(items) ? items : [];
|
|
21
|
+
const tocChunks = tocItems.length > 0 ? chunkArray(tocItems, TOC_ENTRIES_PER_PAGE) : [[]];
|
|
22
|
+
|
|
23
|
+
return tocChunks.map((chunk, pageIndex) => {
|
|
24
|
+
const isContinuation = pageIndex > 0;
|
|
25
|
+
const pageId = pageIndex === 0 ? "toc" : `toc-${String(pageIndex + 1).padStart(2, "0")}`;
|
|
26
|
+
const headingId = pageIndex === 0 ? "toc-title" : `${pageId}-title`;
|
|
27
|
+
const pageHeadingText = isContinuation ? tocContinuationTitle(headingText) : headingText;
|
|
28
|
+
const headingClass = isContinuation ? ` class="toc-heading toc-heading--continuation"` : ` class="toc-heading"`;
|
|
29
|
+
const tocList = chunk.length > 0
|
|
30
|
+
? `
|
|
31
|
+
<ol class="toc-list">
|
|
32
|
+
${chunk.map((item, index) => {
|
|
33
|
+
const level = item.level === 3 ? 3 : 2;
|
|
34
|
+
const absoluteIndex = pageIndex * TOC_ENTRIES_PER_PAGE + index;
|
|
35
|
+
const label = item.label || (level === 2 ? `#${absoluteIndex + 1}` : "");
|
|
36
|
+
const targetPageIndex = Math.max(0, Number(item.pageNumber || 1) - 1);
|
|
37
|
+
return ` <li class="toc-level-${level}"><a href="#${escapeAttr(item.id)}" data-openpress-anchor="${escapeAttr(item.id)}" data-openpress-target-page-index="${targetPageIndex}"><span class="toc-index" data-toc-index="${escapeAttr(label)}">${escapeHtml(label)}</span><span class="toc-title">${escapeHtml(item.title)}</span><span class="toc-page">${String(item.pageNumber).padStart(2, "0")}</span></a></li>`;
|
|
38
|
+
}).join("\n")}
|
|
39
|
+
</ol>
|
|
40
|
+
`
|
|
41
|
+
: "";
|
|
42
|
+
return renderPageShell(
|
|
43
|
+
tocPageClassName(className, isContinuation),
|
|
44
|
+
`
|
|
45
|
+
<h2 id="${headingId}"${headingClass}>${escapeHtml(pageHeadingText)}</h2>
|
|
46
|
+
${tocList}
|
|
47
|
+
`,
|
|
48
|
+
[
|
|
49
|
+
`id="${pageId}"`,
|
|
50
|
+
`data-page-title="${escapeAttr(headingText)}"`,
|
|
51
|
+
`data-toc-continuation="${isContinuation ? "true" : "false"}"`,
|
|
52
|
+
`aria-labelledby="${headingId}"`,
|
|
53
|
+
].filter(Boolean).join(" "),
|
|
54
|
+
{ kind: "toc", footer: false },
|
|
55
|
+
);
|
|
56
|
+
}).join("\n\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function tocContinuationTitle(title) {
|
|
60
|
+
return title === "目錄" ? "目錄續" : `${title} continued`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pageAttrs(attrs, { kind, footer } = {}) {
|
|
64
|
+
const parts = [];
|
|
65
|
+
if (attrs.trim()) parts.push(attrs.trim());
|
|
66
|
+
if (kind && !hasAttr(attrs, "data-page-kind")) parts.push(`data-page-kind="${escapeAttr(kind)}"`);
|
|
67
|
+
if (footer === false && !hasAttr(attrs, "data-page-footer")) parts.push('data-page-footer="false"');
|
|
68
|
+
return parts.length ? ` ${parts.join(" ")}` : "";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hasAttr(attrs, name) {
|
|
72
|
+
return new RegExp(`\\b${name}=`).test(attrs);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function addClass(className, extraClass) {
|
|
76
|
+
const classes = className.split(/\s+/).filter(Boolean);
|
|
77
|
+
if (!classes.includes(extraClass)) classes.push(extraClass);
|
|
78
|
+
return classes.join(" ");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function injectStaticToc(pages) {
|
|
82
|
+
const tocItems = collectTocItems(pages);
|
|
83
|
+
if (tocItems.length === 0) return pages;
|
|
84
|
+
const tocIndex = pages.findIndex((page) => hasPageKind(page.match(/^<section[^>]*>/i)?.[0] ?? "", "toc"));
|
|
85
|
+
const tocPageCount = Math.max(1, Math.ceil(tocItems.length / TOC_ENTRIES_PER_PAGE));
|
|
86
|
+
const tocPageNumber = tocIndex + 1;
|
|
87
|
+
const adjustedTocItems = tocPageCount > 1 && tocIndex >= 0
|
|
88
|
+
? tocItems.map((item) => ({
|
|
89
|
+
...item,
|
|
90
|
+
pageNumber: item.pageNumber > tocPageNumber ? item.pageNumber + tocPageCount - 1 : item.pageNumber,
|
|
91
|
+
}))
|
|
92
|
+
: tocItems;
|
|
93
|
+
|
|
94
|
+
return pages.map((page) => {
|
|
95
|
+
const openingTag = page.match(/^<section[^>]*>/i)?.[0] ?? "";
|
|
96
|
+
if (!hasPageKind(openingTag, "toc")) return page;
|
|
97
|
+
const title = extractAttr(openingTag, "data-page-title");
|
|
98
|
+
return renderToc({ title, items: adjustedTocItems, className: extractAttr(openingTag, "class") });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function tocPageClassName(className, isContinuation) {
|
|
103
|
+
const classes = new Set(String(className || "reader-page reader-page--toc").split(/\s+/).filter(Boolean));
|
|
104
|
+
classes.delete("toc");
|
|
105
|
+
classes.add("reader-page");
|
|
106
|
+
classes.add("reader-page--toc");
|
|
107
|
+
if (isContinuation) classes.add("toc-continuation");
|
|
108
|
+
else classes.delete("toc-continuation");
|
|
109
|
+
return [...classes].join(" ");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function extractAttr(openingTag, name) {
|
|
113
|
+
const re = new RegExp(`${name}="([^"]*)"`);
|
|
114
|
+
return openingTag.match(re)?.[1];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function collectTocItems(pages) {
|
|
118
|
+
const items = [];
|
|
119
|
+
let chapterIndex = 0;
|
|
120
|
+
let sectionIndex = 0;
|
|
121
|
+
let pendingChapterOpener;
|
|
122
|
+
|
|
123
|
+
pages.forEach((page, index) => {
|
|
124
|
+
const openingTag = page.match(/^<section[^>]*>/i)?.[0] ?? "";
|
|
125
|
+
if (hasPageKind(openingTag, "chapter-opener")) {
|
|
126
|
+
pendingChapterOpener = extractChapterOpenerTarget(page, index);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!hasContentPageKind(openingTag)) return;
|
|
131
|
+
|
|
132
|
+
let pageStartedChapter = false;
|
|
133
|
+
const headings = [...page.matchAll(/<h([23])\b[^>]*\bid="([^"]+)"[^>]*>([\s\S]*?)<\/h\1>/gi)];
|
|
134
|
+
headings.forEach((heading) => {
|
|
135
|
+
const level = Number(heading[1]);
|
|
136
|
+
if (level === 2) {
|
|
137
|
+
const opener = pendingChapterOpener;
|
|
138
|
+
pendingChapterOpener = undefined;
|
|
139
|
+
pageStartedChapter = true;
|
|
140
|
+
chapterIndex += 1;
|
|
141
|
+
sectionIndex = 0;
|
|
142
|
+
items.push({
|
|
143
|
+
id: opener?.id ?? heading[2],
|
|
144
|
+
title: htmlToText(heading[3]),
|
|
145
|
+
pageNumber: opener?.pageNumber ?? index + 1,
|
|
146
|
+
level: 2,
|
|
147
|
+
label: `#${chapterIndex}`,
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (level === 3 && chapterIndex > 0) {
|
|
153
|
+
sectionIndex += 1;
|
|
154
|
+
items.push({
|
|
155
|
+
id: heading[2],
|
|
156
|
+
title: htmlToText(heading[3]),
|
|
157
|
+
pageNumber: index + 1,
|
|
158
|
+
level: 3,
|
|
159
|
+
label: `${chapterIndex}.${sectionIndex}`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
if (!pageStartedChapter) pendingChapterOpener = undefined;
|
|
164
|
+
});
|
|
165
|
+
return items;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function extractChapterOpenerTarget(page, index) {
|
|
169
|
+
const heading = page.match(/<h2\b[^>]*\bid="([^"]+)"[^>]*>([\s\S]*?)<\/h2>/i);
|
|
170
|
+
if (!heading?.[1]) return undefined;
|
|
171
|
+
return {
|
|
172
|
+
id: heading[1],
|
|
173
|
+
pageNumber: index + 1,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function htmlToText(html) {
|
|
178
|
+
return html
|
|
179
|
+
.replace(/<[^>]+>/g, "")
|
|
180
|
+
.replaceAll(" ", " ")
|
|
181
|
+
.replaceAll("&", "&")
|
|
182
|
+
.replaceAll("<", "<")
|
|
183
|
+
.replaceAll(">", ">")
|
|
184
|
+
.replaceAll(""", '"')
|
|
185
|
+
.trim();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function hasPageKind(openingTag, kind) {
|
|
189
|
+
return extractAttr(openingTag, "data-page-kind") === kind;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function hasContentPageKind(openingTag) {
|
|
193
|
+
return extractAttr(openingTag, "data-page-kind") === "content";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function escapeAttr(value) {
|
|
197
|
+
return String(value)
|
|
198
|
+
.replaceAll("&", "&")
|
|
199
|
+
.replaceAll('"', """)
|
|
200
|
+
.replaceAll("<", "<")
|
|
201
|
+
.replaceAll(">", ">");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function escapeHtml(value) {
|
|
205
|
+
return String(value)
|
|
206
|
+
.replaceAll("&", "&")
|
|
207
|
+
.replaceAll("<", "<")
|
|
208
|
+
.replaceAll(">", ">");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function chunkArray(items, size) {
|
|
212
|
+
const chunks = [];
|
|
213
|
+
for (let index = 0; index < items.length; index += size) {
|
|
214
|
+
chunks.push(items.slice(index, index + size));
|
|
215
|
+
}
|
|
216
|
+
return chunks;
|
|
217
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
export async function optimizePdfMediaForStaticRoot(staticRoot) {
|
|
6
|
+
await optimizePdfMedia(path.join(staticRoot, "openpress", "media"));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function optimizePdfMedia(mediaDir) {
|
|
10
|
+
const files = await listImageFiles(mediaDir);
|
|
11
|
+
for (const file of files) {
|
|
12
|
+
const stat = await fs.stat(file);
|
|
13
|
+
if (stat.size < 320 * 1024) continue;
|
|
14
|
+
|
|
15
|
+
const ext = path.extname(file).toLowerCase();
|
|
16
|
+
const args =
|
|
17
|
+
ext === ".jpg" || ext === ".jpeg"
|
|
18
|
+
? ["-Z", "1600", "--setProperty", "formatOptions", "78", file, "--out", file]
|
|
19
|
+
: ["-Z", "1600", file, "--out", file];
|
|
20
|
+
const result = spawnSync("sips", args, { stdio: "ignore" });
|
|
21
|
+
if (result.status !== 0) {
|
|
22
|
+
console.warn(`[pdf] skipped image optimization: ${path.basename(file)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function listImageFiles(dir) {
|
|
28
|
+
let entries;
|
|
29
|
+
try {
|
|
30
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
31
|
+
} catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const files = [];
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
const fullPath = path.join(dir, entry.name);
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
files.push(...(await listImageFiles(fullPath)));
|
|
40
|
+
} else if (/\.(jpe?g|png)$/i.test(entry.name)) {
|
|
41
|
+
files.push(fullPath);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return files;
|
|
45
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadConfig } from "./config.mjs";
|
|
4
|
+
import { copyDirectory, writeComponentsCss, writeContentCss } from "./file-utils.mjs";
|
|
5
|
+
import { copyThemeFonts } from "./fonts.mjs";
|
|
6
|
+
import { copyKatexFonts } from "./katex-assets.mjs";
|
|
7
|
+
|
|
8
|
+
export async function syncPublicAssets(root, publicOutputDir, config) {
|
|
9
|
+
config ??= await loadConfig(root);
|
|
10
|
+
await fs.rm(path.join(publicOutputDir, "report.css"), { force: true });
|
|
11
|
+
for (const name of ["tokens.css"]) {
|
|
12
|
+
await fs.copyFile(path.join(config.paths.themeDir, name), path.join(publicOutputDir, name));
|
|
13
|
+
}
|
|
14
|
+
await writeContentCss(root, publicOutputDir, config);
|
|
15
|
+
await copyThemeFonts(root, publicOutputDir, config);
|
|
16
|
+
await copyKatexFonts(publicOutputDir);
|
|
17
|
+
await writeComponentsCss(root, publicOutputDir, config);
|
|
18
|
+
await copyDirectory(config.paths.mediaDir, path.join(publicOutputDir, "media"));
|
|
19
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import postcss from "postcss";
|
|
3
|
+
|
|
4
|
+
const UNSCOPED_RULE_PARENTS = new Set(["keyframes", "-webkit-keyframes", "page"]);
|
|
5
|
+
|
|
6
|
+
export async function buildChapterScopedCss(workspace) {
|
|
7
|
+
const parts = [];
|
|
8
|
+
for (const chapter of workspace.chapters ?? []) {
|
|
9
|
+
for (const styleFile of chapter.styleFiles ?? []) {
|
|
10
|
+
const source = await fs.readFile(styleFile.absolutePath, "utf8");
|
|
11
|
+
const scoped = await scopeChapterCss(source, {
|
|
12
|
+
chapterSlug: chapter.slug,
|
|
13
|
+
from: styleFile.absolutePath,
|
|
14
|
+
});
|
|
15
|
+
if (!scoped.trim()) continue;
|
|
16
|
+
parts.push(`/* === ${styleFile.documentPath} === */`);
|
|
17
|
+
parts.push(scoped.trimEnd());
|
|
18
|
+
parts.push("");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return parts.join("\n");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function scopeChapterCss(source, { chapterSlug, from = undefined } = {}) {
|
|
25
|
+
if (typeof source !== "string") throw new Error("scopeChapterCss requires a CSS source string.");
|
|
26
|
+
const slug = cssAttributeValue(chapterSlug);
|
|
27
|
+
const scope = `[data-chapter-slug="${slug}"]`;
|
|
28
|
+
const root = postcss.parse(source, { from });
|
|
29
|
+
|
|
30
|
+
root.walkRules((rule) => {
|
|
31
|
+
if (isInsideUnscopedAtRule(rule)) return;
|
|
32
|
+
rule.selector = `${scope} :where(${rule.selector})`;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return root.toString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isInsideUnscopedAtRule(node) {
|
|
39
|
+
let current = node.parent;
|
|
40
|
+
while (current) {
|
|
41
|
+
if (current.type === "atrule" && UNSCOPED_RULE_PARENTS.has(current.name.toLowerCase())) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
current = current.parent;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function cssAttributeValue(value) {
|
|
50
|
+
return String(value ?? "")
|
|
51
|
+
.replaceAll("\\", "\\\\")
|
|
52
|
+
.replaceAll('"', '\\"');
|
|
53
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clearCommentMarkers,
|
|
3
|
+
insertCommentMarker,
|
|
4
|
+
listCommentMarkers,
|
|
5
|
+
updateCommentMarker,
|
|
6
|
+
} from "./comment-marker.mjs";
|
|
7
|
+
|
|
8
|
+
const MAX_COMMENT_BODY_BYTES = 64 * 1024;
|
|
9
|
+
|
|
10
|
+
export async function handleCommentRequest(req, res, {
|
|
11
|
+
root = ".",
|
|
12
|
+
id = undefined,
|
|
13
|
+
timestamp = undefined,
|
|
14
|
+
} = {}) {
|
|
15
|
+
if (req.method === "GET") {
|
|
16
|
+
try {
|
|
17
|
+
writeJson(res, 200, { ok: true, comments: await listCommentMarkers({ root }) });
|
|
18
|
+
} catch (error) {
|
|
19
|
+
writeJson(res, 400, {
|
|
20
|
+
ok: false,
|
|
21
|
+
message: error instanceof Error ? error.message : String(error),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (req.method === "DELETE") {
|
|
28
|
+
try {
|
|
29
|
+
const body = await readJsonBody(req);
|
|
30
|
+
const result = await clearCommentMarkers({
|
|
31
|
+
root,
|
|
32
|
+
id: body?.id,
|
|
33
|
+
all: body?.all === true,
|
|
34
|
+
});
|
|
35
|
+
writeJson(res, 200, { ok: true, ...result });
|
|
36
|
+
} catch (error) {
|
|
37
|
+
writeJson(res, 400, {
|
|
38
|
+
ok: false,
|
|
39
|
+
message: error instanceof Error ? error.message : String(error),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (req.method === "PATCH") {
|
|
46
|
+
try {
|
|
47
|
+
const body = await readJsonBody(req);
|
|
48
|
+
const result = await updateCommentMarker({
|
|
49
|
+
root,
|
|
50
|
+
id: body?.id,
|
|
51
|
+
note: body?.note,
|
|
52
|
+
hint: body?.hint,
|
|
53
|
+
timestamp,
|
|
54
|
+
});
|
|
55
|
+
writeJson(res, 200, {
|
|
56
|
+
ok: true,
|
|
57
|
+
comment: {
|
|
58
|
+
id: result.id,
|
|
59
|
+
timestamp: result.timestamp,
|
|
60
|
+
path: result.path,
|
|
61
|
+
line: result.line,
|
|
62
|
+
note: result.note,
|
|
63
|
+
hint: result.hint,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
} catch (error) {
|
|
67
|
+
writeJson(res, 400, {
|
|
68
|
+
ok: false,
|
|
69
|
+
message: error instanceof Error ? error.message : String(error),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (req.method !== "POST") {
|
|
76
|
+
writeJson(res, 405, { ok: false, message: "OpenPress comment endpoint requires GET, POST, PATCH, or DELETE." });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const body = await readJsonBody(req);
|
|
82
|
+
const target = body?.target ?? {};
|
|
83
|
+
const result = await insertCommentMarker({
|
|
84
|
+
root,
|
|
85
|
+
path: target.path ?? body?.path,
|
|
86
|
+
source: target.source ?? body?.source,
|
|
87
|
+
note: body?.note,
|
|
88
|
+
hint: body?.hint,
|
|
89
|
+
id,
|
|
90
|
+
timestamp,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
writeJson(res, 200, {
|
|
94
|
+
ok: true,
|
|
95
|
+
comment: {
|
|
96
|
+
id: result.id,
|
|
97
|
+
timestamp: result.timestamp,
|
|
98
|
+
path: result.path,
|
|
99
|
+
line: result.line,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
} catch (error) {
|
|
103
|
+
writeJson(res, 400, {
|
|
104
|
+
ok: false,
|
|
105
|
+
message: error instanceof Error ? error.message : String(error),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function readJsonBody(req) {
|
|
111
|
+
let body = "";
|
|
112
|
+
for await (const chunk of req) {
|
|
113
|
+
body += String(chunk);
|
|
114
|
+
if (Buffer.byteLength(body, "utf8") > MAX_COMMENT_BODY_BYTES) {
|
|
115
|
+
throw new Error("OpenPress comment request body is too large.");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(body || "{}");
|
|
120
|
+
} catch {
|
|
121
|
+
throw new Error("OpenPress comment request body must be valid JSON.");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function writeJson(res, status, body) {
|
|
126
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
127
|
+
res.end(`${JSON.stringify(body, null, 2)}\n`);
|
|
128
|
+
}
|