@open-press/core 1.1.2 → 1.1.4
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/engine/cli.mjs +1 -1
- package/engine/commands/_shared.mjs +13 -2
- package/engine/commands/image.mjs +11 -1
- package/engine/output/chrome-pdf.mjs +155 -52
- package/engine/react/document-entry.mjs +9 -1
- package/engine/react/document-export.mjs +13 -1
- package/engine/react/press-tree-inspection.mjs +2 -0
- package/engine/react/text-source-transform.mjs +175 -0
- package/engine/runtime/inspection.mjs +65 -7
- package/engine/runtime/page-selector.mjs +87 -0
- package/engine/runtime/source-text-tools.mjs +71 -6
- package/package.json +1 -1
- package/src/openpress/app/OpenPressApp.tsx +97 -28
- package/src/openpress/app/OpenPressRuntime.tsx +27 -2
- package/src/openpress/core/Press.tsx +1 -0
- package/src/openpress/core/types.ts +6 -6
- package/src/openpress/document-model/documentTypes.ts +3 -0
- package/src/openpress/document-model/workspaceManifestModel.ts +4 -0
- package/src/openpress/reader/SlidePresentationPage.tsx +221 -0
- package/src/openpress/reader/index.ts +1 -0
- package/src/openpress/reader/usePanelState.ts +7 -4
- package/src/openpress/shared/runtimeMode.ts +7 -0
- package/src/openpress/workbench/Workbench.tsx +30 -2
- package/src/openpress/workbench/document/hooks/useInlineDocumentEditor.ts +84 -6
- package/src/openpress/workbench/shell/WorkbenchShell.tsx +7 -0
- package/src/styles/openpress/reader-runtime.css +11 -53
- package/src/styles/openpress/workbench-panels.css +1 -0
- package/src/styles/openpress/workbench.css +149 -0
package/engine/cli.mjs
CHANGED
|
@@ -85,7 +85,7 @@ Commands:
|
|
|
85
85
|
preview --renderer react [--host 127.0.0.1] [--port 5173] [--no-build] [--dry-run]
|
|
86
86
|
dev --renderer react [--host 127.0.0.1] [--port 5173] [--no-build] [--dry-run]
|
|
87
87
|
typecheck
|
|
88
|
-
image [--output <outputDir>] [--no-build] [--dry-run]
|
|
88
|
+
image [--output <outputDir>] [--pages <selector>] [--no-build] [--dry-run]
|
|
89
89
|
pdf [--output <outputDir>/<pdf.filename>] [--no-build] [--dry-run]
|
|
90
90
|
deploy --confirm [--dry-run]
|
|
91
91
|
doctor [--json] [--no-cache] # version + skill staleness check
|
|
@@ -40,6 +40,7 @@ export function parseOptions(argv) {
|
|
|
40
40
|
else if (value === "--scope") options.scope = argv[++i];
|
|
41
41
|
else if (value === "--source") options.source = argv[++i];
|
|
42
42
|
else if (value === "--output") options.output = argv[++i];
|
|
43
|
+
else if (value === "--pages") options.pages = argv[++i];
|
|
43
44
|
else if (value.startsWith("--")) throw new Error(`Unknown option: ${value}`);
|
|
44
45
|
else positional.push(value);
|
|
45
46
|
}
|
|
@@ -145,6 +146,7 @@ export async function buildReactImages({
|
|
|
145
146
|
port = "5186",
|
|
146
147
|
noBuild = false,
|
|
147
148
|
recurse,
|
|
149
|
+
pageSelector = null,
|
|
148
150
|
}) {
|
|
149
151
|
config ??= await loadConfig(root);
|
|
150
152
|
outDir ??= path.join(config.paths.outputDir, "images");
|
|
@@ -162,9 +164,18 @@ export async function buildReactImages({
|
|
|
162
164
|
debuggingPortBase: 9700,
|
|
163
165
|
debuggingPortRange: 600,
|
|
164
166
|
profilePrefix: "chrome-image",
|
|
167
|
+
pageSelector,
|
|
165
168
|
});
|
|
166
|
-
|
|
167
|
-
|
|
169
|
+
const label = pageSelector
|
|
170
|
+
? `${result.files.length}/${result.pageCount} OpenPress pages exported to PNG`
|
|
171
|
+
: `${result.files.length} OpenPress pages exported to PNG`;
|
|
172
|
+
console.log(label);
|
|
173
|
+
return {
|
|
174
|
+
outDir,
|
|
175
|
+
files: result.files,
|
|
176
|
+
pageCount: result.pageCount,
|
|
177
|
+
selectedPageNumbers: result.selectedPageNumbers,
|
|
178
|
+
};
|
|
168
179
|
} finally {
|
|
169
180
|
await stopChildProcess(server);
|
|
170
181
|
}
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { STATIC_SERVER, buildReactImages, formatNodeScriptCommand, formatOpenPressCommand } from "./_shared.mjs";
|
|
3
|
+
import { parsePageSelector } from "../runtime/page-selector.mjs";
|
|
3
4
|
|
|
4
5
|
export async function run({ root, config, options, recurse }) {
|
|
5
6
|
const outputDir = options.output ? path.resolve(root, options.output) : path.join(config.paths.outputDir, "images");
|
|
6
7
|
const host = options.host ?? "127.0.0.1";
|
|
7
8
|
const port = options.port ?? "5186";
|
|
8
9
|
|
|
10
|
+
const pageSelector = options.pages ? parsePageSelector(options.pages) : null;
|
|
11
|
+
|
|
9
12
|
if (options.dryRun) {
|
|
10
13
|
console.log(`Command: ${formatOpenPressCommand(["render", ".", "--renderer", "react"])}`);
|
|
11
14
|
console.log(`Command: ${formatNodeScriptCommand(root, STATIC_SERVER)} ${config.outputDir} --host ${host} --port ${port} --workspace .`);
|
|
12
15
|
console.log(`Chrome image export URL: http://${host}:${port}/?print=1`);
|
|
16
|
+
if (pageSelector) {
|
|
17
|
+
console.log(`Page selector: ${options.pages} (resolved at capture time against the rendered page count)`);
|
|
18
|
+
}
|
|
13
19
|
console.log(`Output: ${path.relative(root, path.join(outputDir, "page-001.png"))}`);
|
|
14
20
|
return 0;
|
|
15
21
|
}
|
|
@@ -22,8 +28,12 @@ export async function run({ root, config, options, recurse }) {
|
|
|
22
28
|
port,
|
|
23
29
|
noBuild: options.noBuild,
|
|
24
30
|
recurse,
|
|
31
|
+
pageSelector,
|
|
25
32
|
});
|
|
26
33
|
|
|
27
|
-
|
|
34
|
+
const suffix = pageSelector
|
|
35
|
+
? ` (${result.files.length}/${result.pageCount} pages)`
|
|
36
|
+
: ` (${result.files.length} pages)`;
|
|
37
|
+
console.log(`OpenPress images: ${path.relative(root, result.outDir)}${suffix}`);
|
|
28
38
|
return 0;
|
|
29
39
|
}
|
|
@@ -2,6 +2,9 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { resolvePageSelector } from "../runtime/page-selector.mjs";
|
|
6
|
+
|
|
7
|
+
const defaultResolveSelector = resolvePageSelector;
|
|
5
8
|
|
|
6
9
|
const CHROME_CANDIDATE_PATHS = {
|
|
7
10
|
darwin: [
|
|
@@ -137,6 +140,7 @@ export async function printUrlToPdf({
|
|
|
137
140
|
await preparePdfPage(client, { viewport });
|
|
138
141
|
await client.send("Page.navigate", { url });
|
|
139
142
|
const readyResult = await waitForReady(client);
|
|
143
|
+
warnAboutOverflowingPages("PDF", readyResult);
|
|
140
144
|
const result = await client.send("Page.printToPDF", {
|
|
141
145
|
...DEFAULT_PRINT_OPTIONS,
|
|
142
146
|
...printOptions,
|
|
@@ -152,6 +156,16 @@ export async function printUrlToPdf({
|
|
|
152
156
|
}
|
|
153
157
|
}
|
|
154
158
|
|
|
159
|
+
function warnAboutOverflowingPages(label, readyResult) {
|
|
160
|
+
const overflowing = Array.isArray(readyResult?.overflowingPageNumbers) ? readyResult.overflowingPageNumbers : [];
|
|
161
|
+
if (overflowing.length === 0) return;
|
|
162
|
+
const preview = overflowing.slice(0, 12).join(", ") + (overflowing.length > 12 ? `, … (+${overflowing.length - 12} more)` : "");
|
|
163
|
+
console.warn(
|
|
164
|
+
`OpenPress ${label}: ${overflowing.length} page(s) exceed the page body bounds (pages ${preview}). ` +
|
|
165
|
+
`Output will still be generated but those pages may clip; run \`openpress inspect\` to locate the overflowing elements.`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
155
169
|
export async function captureUrlPagesToPng({
|
|
156
170
|
root,
|
|
157
171
|
url,
|
|
@@ -162,6 +176,8 @@ export async function captureUrlPagesToPng({
|
|
|
162
176
|
debuggingPortBase = 9700,
|
|
163
177
|
debuggingPortRange = 300,
|
|
164
178
|
profilePrefix = "chrome-image",
|
|
179
|
+
pageSelector = null,
|
|
180
|
+
resolveSelector = null,
|
|
165
181
|
}) {
|
|
166
182
|
chrome ??= resolveChromePath();
|
|
167
183
|
await fs.mkdir(outDir, { recursive: true });
|
|
@@ -189,14 +205,25 @@ export async function captureUrlPagesToPng({
|
|
|
189
205
|
try {
|
|
190
206
|
await preparePdfPage(client, { viewport });
|
|
191
207
|
await client.send("Page.navigate", { url });
|
|
192
|
-
const
|
|
208
|
+
const readyResult = await waitForReady(client);
|
|
209
|
+
warnAboutOverflowingPages("image", readyResult);
|
|
210
|
+
const pageCount = readyResult?.pageCount ?? 0;
|
|
193
211
|
const rects = await getPrintPageRects(client);
|
|
194
212
|
if (rects.length === 0) throw new Error("No OpenPress pages found for image export.");
|
|
195
213
|
|
|
214
|
+
const selectedPageNumbers = pageSelector
|
|
215
|
+
? (resolveSelector ?? defaultResolveSelector)(pageSelector, rects.length)
|
|
216
|
+
: rects.map((_, index) => index + 1);
|
|
217
|
+
if (selectedPageNumbers.length === 0) {
|
|
218
|
+
throw new Error("Page selector resolved to zero pages; nothing to export.");
|
|
219
|
+
}
|
|
220
|
+
|
|
196
221
|
const padWidth = Math.max(3, String(rects.length).length);
|
|
197
222
|
const files = [];
|
|
198
|
-
for (const
|
|
199
|
-
const
|
|
223
|
+
for (const pageNumber of selectedPageNumbers) {
|
|
224
|
+
const rect = rects[pageNumber - 1];
|
|
225
|
+
if (!rect) continue;
|
|
226
|
+
const filename = `page-${String(pageNumber).padStart(padWidth, "0")}.png`;
|
|
200
227
|
const filePath = path.join(outDir, filename);
|
|
201
228
|
const result = await client.send("Page.captureScreenshot", {
|
|
202
229
|
format: "png",
|
|
@@ -214,7 +241,7 @@ export async function captureUrlPagesToPng({
|
|
|
214
241
|
files.push(filePath);
|
|
215
242
|
}
|
|
216
243
|
|
|
217
|
-
return { pageCount, files };
|
|
244
|
+
return { pageCount, files, selectedPageNumbers };
|
|
218
245
|
} finally {
|
|
219
246
|
client.close();
|
|
220
247
|
}
|
|
@@ -282,59 +309,135 @@ export async function evaluateUrlWithChrome({
|
|
|
282
309
|
}
|
|
283
310
|
}
|
|
284
311
|
|
|
285
|
-
export
|
|
286
|
-
|
|
287
|
-
|
|
312
|
+
export const PRINT_READY_DEFAULTS = Object.freeze({
|
|
313
|
+
totalTimeoutMs: 300_000,
|
|
314
|
+
idleTimeoutMs: 30_000,
|
|
315
|
+
pollIntervalMs: 100,
|
|
316
|
+
stableMs: 300,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
export function resolvePrintReadyTiming(env = process.env) {
|
|
320
|
+
const total = Number(env.OPENPRESS_PRINT_READY_TIMEOUT_MS);
|
|
321
|
+
const idle = Number(env.OPENPRESS_PRINT_READY_IDLE_MS);
|
|
322
|
+
const stable = Number(env.OPENPRESS_PRINT_READY_STABLE_MS);
|
|
323
|
+
return {
|
|
324
|
+
totalTimeoutMs: Number.isFinite(total) && total > 0 ? total : PRINT_READY_DEFAULTS.totalTimeoutMs,
|
|
325
|
+
idleTimeoutMs: Number.isFinite(idle) && idle > 0 ? idle : PRINT_READY_DEFAULTS.idleTimeoutMs,
|
|
326
|
+
stableMs: Number.isFinite(stable) && stable >= 0 ? stable : PRINT_READY_DEFAULTS.stableMs,
|
|
327
|
+
pollIntervalMs: PRINT_READY_DEFAULTS.pollIntervalMs,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function printReadinessExpression() {
|
|
332
|
+
return `Promise.resolve().then(async () => {
|
|
333
|
+
const root = document.querySelector('[data-openpress-print-document="true"]');
|
|
334
|
+
if (!root) return { pageCount: 0, overflowingPages: 0, overflowingPageNumbers: [] };
|
|
335
|
+
const candidates = root.querySelectorAll('.openpress-html-page');
|
|
336
|
+
if (candidates.length === 0) return { pageCount: 0, overflowingPages: 0, overflowingPageNumbers: [] };
|
|
337
|
+
|
|
338
|
+
await document.fonts?.ready;
|
|
339
|
+
await Promise.all(Array.from(document.images).map(async (img) => {
|
|
340
|
+
if (!img.complete) {
|
|
341
|
+
await new Promise((resolve) => {
|
|
342
|
+
const settle = () => {
|
|
343
|
+
img.removeEventListener('load', settle);
|
|
344
|
+
img.removeEventListener('error', settle);
|
|
345
|
+
resolve();
|
|
346
|
+
};
|
|
347
|
+
img.addEventListener('load', settle, { once: true });
|
|
348
|
+
img.addEventListener('error', settle, { once: true });
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
await img.decode?.().catch(() => undefined);
|
|
352
|
+
}));
|
|
353
|
+
|
|
354
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
355
|
+
|
|
356
|
+
const pages = Array.from(document.querySelectorAll('.openpress-public-page > .openpress-html-page'));
|
|
357
|
+
const contentFitsPageBody = (body) => {
|
|
358
|
+
const bodyBottom = body.getBoundingClientRect().bottom;
|
|
359
|
+
const contentBottom = Array.from(body.children).reduce((bottom, child) => {
|
|
360
|
+
if (getComputedStyle(child).display === 'none') return bottom;
|
|
361
|
+
const marginBottom = Number.parseFloat(getComputedStyle(child).marginBottom) || 0;
|
|
362
|
+
return Math.max(bottom, child.getBoundingClientRect().bottom + marginBottom);
|
|
363
|
+
}, body.getBoundingClientRect().top);
|
|
364
|
+
return contentBottom <= bodyBottom + 1;
|
|
365
|
+
};
|
|
366
|
+
const overflowingPageNumbers = pages.reduce((nums, page, index) => {
|
|
367
|
+
const body = page.querySelector('.page-body');
|
|
368
|
+
if (body && !contentFitsPageBody(body)) nums.push(index + 1);
|
|
369
|
+
return nums;
|
|
370
|
+
}, []);
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
pageCount: pages.length,
|
|
374
|
+
overflowingPages: overflowingPageNumbers.length,
|
|
375
|
+
overflowingPageNumbers,
|
|
376
|
+
};
|
|
377
|
+
})`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function formatPrintReadyTimeoutMessage(reason, snapshot, timing, elapsedMs) {
|
|
381
|
+
const seconds = Math.round(elapsedMs / 1000);
|
|
382
|
+
const observed = `(observed ${snapshot.pageCount} page(s), ${snapshot.overflowingPages} overflowing)`;
|
|
383
|
+
if (reason === "idle") {
|
|
384
|
+
return (
|
|
385
|
+
`Timed out waiting for OpenPress pagination before PDF export. ` +
|
|
386
|
+
`No progress for ${seconds}s ${observed}. ` +
|
|
387
|
+
`Raise OPENPRESS_PRINT_READY_IDLE_MS (currently ${timing.idleTimeoutMs}ms) to extend the idle window.`
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
return (
|
|
391
|
+
`Timed out waiting for OpenPress pagination before PDF export. ` +
|
|
392
|
+
`Total ${seconds}s exceeded ${observed}. ` +
|
|
393
|
+
`Raise OPENPRESS_PRINT_READY_TIMEOUT_MS (currently ${timing.totalTimeoutMs}ms) to extend the hard cap.`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export async function waitForPrintReady(client, timing = resolvePrintReadyTiming()) {
|
|
398
|
+
const startedAt = Date.now();
|
|
399
|
+
let lastSignature = "";
|
|
400
|
+
let lastProgressAt = startedAt;
|
|
401
|
+
let stableSince = startedAt;
|
|
402
|
+
let lastSnapshot = { pageCount: 0, overflowingPages: 0, overflowingPageNumbers: [] };
|
|
403
|
+
|
|
404
|
+
while (true) {
|
|
405
|
+
const totalElapsed = Date.now() - startedAt;
|
|
406
|
+
if (totalElapsed > timing.totalTimeoutMs) {
|
|
407
|
+
throw new Error(formatPrintReadyTimeoutMessage("total", lastSnapshot, timing, totalElapsed));
|
|
408
|
+
}
|
|
409
|
+
|
|
288
410
|
const result = await client.send("Runtime.evaluate", {
|
|
289
411
|
returnByValue: true,
|
|
290
412
|
awaitPromise: true,
|
|
291
|
-
expression:
|
|
292
|
-
const root = document.querySelector('[data-openpress-print-document="true"]');
|
|
293
|
-
if (!root || root.querySelectorAll('.openpress-html-page').length === 0) return 0;
|
|
294
|
-
|
|
295
|
-
await document.fonts?.ready;
|
|
296
|
-
await Promise.all(Array.from(document.images).map(async (img) => {
|
|
297
|
-
if (!img.complete) {
|
|
298
|
-
await new Promise((resolve) => {
|
|
299
|
-
const settle = () => {
|
|
300
|
-
img.removeEventListener('load', settle);
|
|
301
|
-
img.removeEventListener('error', settle);
|
|
302
|
-
resolve();
|
|
303
|
-
};
|
|
304
|
-
|
|
305
|
-
img.addEventListener('load', settle, { once: true });
|
|
306
|
-
img.addEventListener('error', settle, { once: true });
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
await img.decode?.().catch(() => undefined);
|
|
311
|
-
}));
|
|
312
|
-
|
|
313
|
-
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
314
|
-
|
|
315
|
-
const pages = Array.from(document.querySelectorAll('.openpress-public-page > .openpress-html-page'));
|
|
316
|
-
const contentFitsPageBody = (body) => {
|
|
317
|
-
const bodyBottom = body.getBoundingClientRect().bottom;
|
|
318
|
-
const contentBottom = Array.from(body.children).reduce((bottom, child) => {
|
|
319
|
-
if (getComputedStyle(child).display === 'none') return bottom;
|
|
320
|
-
const marginBottom = Number.parseFloat(getComputedStyle(child).marginBottom) || 0;
|
|
321
|
-
return Math.max(bottom, child.getBoundingClientRect().bottom + marginBottom);
|
|
322
|
-
}, body.getBoundingClientRect().top);
|
|
323
|
-
return contentBottom <= bodyBottom + 1;
|
|
324
|
-
};
|
|
325
|
-
const bodyOverflowSafe = pages.every((page) => {
|
|
326
|
-
const body = page.querySelector('.page-body');
|
|
327
|
-
return !body || contentFitsPageBody(body);
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
return pages.length > 0 && bodyOverflowSafe ? pages.length : 0;
|
|
331
|
-
})`,
|
|
413
|
+
expression: printReadinessExpression(),
|
|
332
414
|
});
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
415
|
+
const value = result.result?.value ?? {};
|
|
416
|
+
const pageCount = Number.isFinite(Number(value.pageCount)) ? Number(value.pageCount) : 0;
|
|
417
|
+
const overflowingPages = Number.isFinite(Number(value.overflowingPages)) ? Number(value.overflowingPages) : 0;
|
|
418
|
+
const overflowingPageNumbers = Array.isArray(value.overflowingPageNumbers)
|
|
419
|
+
? value.overflowingPageNumbers.map(Number).filter(Number.isFinite)
|
|
420
|
+
: [];
|
|
421
|
+
lastSnapshot = { pageCount, overflowingPages, overflowingPageNumbers };
|
|
422
|
+
|
|
423
|
+
const signature = `${pageCount}:${overflowingPages}`;
|
|
424
|
+
if (signature !== lastSignature) {
|
|
425
|
+
lastSignature = signature;
|
|
426
|
+
stableSince = Date.now();
|
|
427
|
+
lastProgressAt = Date.now();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (pageCount > 0 && Date.now() - stableSince >= timing.stableMs) {
|
|
431
|
+
return { pageCount, overflowingPageNumbers };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const idleElapsed = Date.now() - lastProgressAt;
|
|
435
|
+
if (idleElapsed > timing.idleTimeoutMs) {
|
|
436
|
+
throw new Error(formatPrintReadyTimeoutMessage("idle", lastSnapshot, timing, idleElapsed));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
await delay(timing.pollIntervalMs);
|
|
336
440
|
}
|
|
337
|
-
throw new Error("Timed out waiting for OpenPress pagination before PDF export.");
|
|
338
441
|
}
|
|
339
442
|
|
|
340
443
|
async function getPrintPageRects(client) {
|
|
@@ -14,6 +14,7 @@ import ts from "typescript";
|
|
|
14
14
|
import { createServer as createViteServer } from "vite";
|
|
15
15
|
import { loadConfig } from "../runtime/config.mjs";
|
|
16
16
|
import { inspectPressTree } from "./press-tree-inspection.mjs";
|
|
17
|
+
import { textSourceTransformPlugin } from "./text-source-transform.mjs";
|
|
17
18
|
|
|
18
19
|
const ENGINE_REACT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
19
20
|
const FRAMEWORK_ROOT = path.resolve(ENGINE_REACT_DIR, "..", "..");
|
|
@@ -95,7 +96,14 @@ export async function createReactSsrServer(workspaceRoot = ".") {
|
|
|
95
96
|
cacheDir: path.join(resolvedWorkspaceRoot, ".openpress", "vite-ssr"),
|
|
96
97
|
appType: "custom",
|
|
97
98
|
logLevel: "silent",
|
|
98
|
-
plugins: [
|
|
99
|
+
plugins: [
|
|
100
|
+
textSourceTransformPlugin({
|
|
101
|
+
workspaceRoot: resolvedWorkspaceRoot,
|
|
102
|
+
documentRoot: path.join(resolvedWorkspaceRoot, "press"),
|
|
103
|
+
}),
|
|
104
|
+
reactRuntimePlugin(),
|
|
105
|
+
react(),
|
|
106
|
+
],
|
|
99
107
|
resolve: {
|
|
100
108
|
alias: [
|
|
101
109
|
// ORDER MATTERS: subpath aliases must precede the base alias so that
|
|
@@ -24,6 +24,7 @@ import { resolveAllSources } from "./sources/mdx-resolver.mjs";
|
|
|
24
24
|
import { discoverSectionStyles } from "./style-discovery.mjs";
|
|
25
25
|
|
|
26
26
|
const MAX_ITERATIONS = 20;
|
|
27
|
+
const PRESS_TYPES = new Set(["pages", "slides"]);
|
|
27
28
|
|
|
28
29
|
export async function exportReactDocument(root = ".", { syncAssets = true } = {}) {
|
|
29
30
|
const workspaceRoot = path.resolve(root);
|
|
@@ -115,6 +116,7 @@ export async function exportReactDocument(root = ".", { syncAssets = true } = {}
|
|
|
115
116
|
presses: pressResults.map((r) => ({
|
|
116
117
|
slug: r.slug,
|
|
117
118
|
title: r.readerDocument.meta.title,
|
|
119
|
+
type: r.pressType,
|
|
118
120
|
page: r.readerDocument.theme ?? null,
|
|
119
121
|
pageCount: r.pageCount,
|
|
120
122
|
documentUrl: r.documentUrl,
|
|
@@ -158,6 +160,7 @@ async function exportSinglePress({
|
|
|
158
160
|
const slug = typeof press.metadata?.slug === "string" && press.metadata.slug.trim()
|
|
159
161
|
? press.metadata.slug.trim()
|
|
160
162
|
: "";
|
|
163
|
+
const pressType = normalizePressType(press.metadata?.type);
|
|
161
164
|
|
|
162
165
|
// Effective config for this press: workspace config with per-press
|
|
163
166
|
// metadata overlaid. Press JSX page prop wins over the workspace page.
|
|
@@ -308,6 +311,7 @@ async function exportSinglePress({
|
|
|
308
311
|
const readerDocument = {
|
|
309
312
|
meta: {
|
|
310
313
|
title: trimmedString(effectiveConfig.title) ?? "Untitled Document",
|
|
314
|
+
type: pressType,
|
|
311
315
|
subtitle: trimmedString(effectiveConfig.subtitle) ?? "",
|
|
312
316
|
organization: trimmedString(effectiveConfig.organization) ?? "",
|
|
313
317
|
workspaceLabel: trimmedString(effectiveConfig.workspaceLabel) ?? "",
|
|
@@ -349,6 +353,7 @@ async function exportSinglePress({
|
|
|
349
353
|
|
|
350
354
|
return {
|
|
351
355
|
slug,
|
|
356
|
+
pressType,
|
|
352
357
|
documentPath,
|
|
353
358
|
documentUrl: slug ? `/openpress/${slug}/document.json` : "/openpress/document.json",
|
|
354
359
|
readerDocument,
|
|
@@ -356,6 +361,14 @@ async function exportSinglePress({
|
|
|
356
361
|
};
|
|
357
362
|
}
|
|
358
363
|
|
|
364
|
+
function normalizePressType(value) {
|
|
365
|
+
if (value === undefined || value === null || value === "") return "pages";
|
|
366
|
+
if (PRESS_TYPES.has(value)) return value;
|
|
367
|
+
throw new Error(
|
|
368
|
+
`Unsupported Press type "${value}". Supported types: ${[...PRESS_TYPES].join(", ")}.`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
359
372
|
// Apply per-Press JSX prop overrides onto the workspace-level config.
|
|
360
373
|
// Returns a new config object — the original is untouched so other
|
|
361
374
|
// presses in the same workspace get a clean base.
|
|
@@ -509,4 +522,3 @@ function collectSectionRoots(presses, documentRoot) {
|
|
|
509
522
|
}
|
|
510
523
|
return [...roots];
|
|
511
524
|
}
|
|
512
|
-
|
|
@@ -31,6 +31,7 @@ import React from "react";
|
|
|
31
31
|
* props: Record<string, unknown>, // Press JSX props (no children)
|
|
32
32
|
* metadata: {
|
|
33
33
|
* title?: string,
|
|
34
|
+
* type?: "pages" | "slides",
|
|
34
35
|
* page?: unknown,
|
|
35
36
|
* slug?: string,
|
|
36
37
|
* theme?: string,
|
|
@@ -144,6 +145,7 @@ function collectPressElements(root, PRESS_MARKER) {
|
|
|
144
145
|
function pickPressMetadata(pressProps) {
|
|
145
146
|
const out = {};
|
|
146
147
|
if (typeof pressProps.title === "string") out.title = pressProps.title;
|
|
148
|
+
if (typeof pressProps.type === "string") out.type = pressProps.type;
|
|
147
149
|
if (pressProps.page !== undefined) out.page = pressProps.page;
|
|
148
150
|
if (typeof pressProps.slug === "string") out.slug = pressProps.slug;
|
|
149
151
|
if (typeof pressProps.theme === "string") out.theme = pressProps.theme;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
|
|
5
|
+
const TEXT_SOURCE_FILE_RE = /\.[jt]sx$/;
|
|
6
|
+
|
|
7
|
+
export function textSourceTransformPlugin({ workspaceRoot, documentRoot }) {
|
|
8
|
+
const resolvedWorkspaceRoot = realpathIfExists(path.resolve(workspaceRoot));
|
|
9
|
+
const resolvedDocumentRoot = realpathIfExists(path.resolve(documentRoot));
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
name: "openpress-text-source-transform",
|
|
13
|
+
enforce: "pre",
|
|
14
|
+
transform(code, id) {
|
|
15
|
+
const filePath = cleanViteId(id);
|
|
16
|
+
if (!TEXT_SOURCE_FILE_RE.test(filePath)) return null;
|
|
17
|
+
if (!isInsidePath(filePath, resolvedDocumentRoot)) return null;
|
|
18
|
+
|
|
19
|
+
const relativePath = path.relative(resolvedWorkspaceRoot, filePath).replaceAll(path.sep, "/");
|
|
20
|
+
if (!relativePath || relativePath.startsWith("..")) return null;
|
|
21
|
+
|
|
22
|
+
const nextCode = addLiteralTextSourceProps(code, {
|
|
23
|
+
filePath,
|
|
24
|
+
sourcePath: relativePath,
|
|
25
|
+
});
|
|
26
|
+
if (nextCode === code) return null;
|
|
27
|
+
return { code: nextCode, map: null };
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function addLiteralTextSourceProps(code, { filePath = "index.tsx", sourcePath = "press/index.tsx" } = {}) {
|
|
33
|
+
const sourceFile = ts.createSourceFile(filePath, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
34
|
+
const textRefs = collectOpenPressTextRefs(sourceFile);
|
|
35
|
+
if (textRefs.identifiers.size === 0 && textRefs.namespaces.size === 0) return code;
|
|
36
|
+
|
|
37
|
+
const insertions = [];
|
|
38
|
+
|
|
39
|
+
const visit = (node) => {
|
|
40
|
+
if (ts.isJsxElement(node) && isTextElementName(node.openingElement.tagName, textRefs)) {
|
|
41
|
+
const opening = node.openingElement;
|
|
42
|
+
if (!hasJsxAttribute(opening, "source")) {
|
|
43
|
+
const literal = literalTextChildRange(node, sourceFile, code);
|
|
44
|
+
if (literal) {
|
|
45
|
+
insertions.push({
|
|
46
|
+
offset: opening.end - 1,
|
|
47
|
+
text: ` source={${sourcePropExpression({
|
|
48
|
+
sourcePath,
|
|
49
|
+
objectId: stringLiteralAttribute(opening, "objectId"),
|
|
50
|
+
range: literal.range,
|
|
51
|
+
})}}`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
ts.forEachChild(node, visit);
|
|
57
|
+
};
|
|
58
|
+
visit(sourceFile);
|
|
59
|
+
|
|
60
|
+
if (insertions.length === 0) return code;
|
|
61
|
+
let out = code;
|
|
62
|
+
for (const insertion of insertions.sort((a, b) => b.offset - a.offset)) {
|
|
63
|
+
out = `${out.slice(0, insertion.offset)}${insertion.text}${out.slice(insertion.offset)}`;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function collectOpenPressTextRefs(sourceFile) {
|
|
69
|
+
const identifiers = new Set();
|
|
70
|
+
const namespaces = new Set();
|
|
71
|
+
|
|
72
|
+
for (const statement of sourceFile.statements) {
|
|
73
|
+
if (!ts.isImportDeclaration(statement)) continue;
|
|
74
|
+
if (!statement.importClause) continue;
|
|
75
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
76
|
+
if (statement.moduleSpecifier.text !== "@open-press/core") continue;
|
|
77
|
+
|
|
78
|
+
const bindings = statement.importClause.namedBindings;
|
|
79
|
+
if (!bindings) continue;
|
|
80
|
+
if (ts.isNamespaceImport(bindings)) {
|
|
81
|
+
namespaces.add(bindings.name.text);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!ts.isNamedImports(bindings)) continue;
|
|
85
|
+
|
|
86
|
+
for (const element of bindings.elements) {
|
|
87
|
+
const importedName = element.propertyName?.text ?? element.name.text;
|
|
88
|
+
if (importedName === "Text") identifiers.add(element.name.text);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { identifiers, namespaces };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function literalTextChildRange(node, sourceFile, code) {
|
|
96
|
+
const textChildren = [];
|
|
97
|
+
for (const child of node.children) {
|
|
98
|
+
if (ts.isJsxText(child)) {
|
|
99
|
+
const raw = code.slice(child.pos, child.end);
|
|
100
|
+
if (raw.trim()) textChildren.push({ child, raw });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (ts.isJsxExpression(child) && !child.expression && code.slice(child.pos, child.end).trim() === "{}") continue;
|
|
104
|
+
if (code.slice(child.pos, child.end).trim()) return null;
|
|
105
|
+
}
|
|
106
|
+
if (textChildren.length !== 1) return null;
|
|
107
|
+
|
|
108
|
+
const { child, raw } = textChildren[0];
|
|
109
|
+
const text = raw.trim();
|
|
110
|
+
const startInRaw = raw.indexOf(text);
|
|
111
|
+
const startOffset = child.pos + startInRaw;
|
|
112
|
+
const endOffset = startOffset + text.length;
|
|
113
|
+
const start = sourceFile.getLineAndCharacterOfPosition(startOffset);
|
|
114
|
+
const end = sourceFile.getLineAndCharacterOfPosition(endOffset);
|
|
115
|
+
return {
|
|
116
|
+
text,
|
|
117
|
+
range: {
|
|
118
|
+
line: start.line + 1,
|
|
119
|
+
column: start.character + 1,
|
|
120
|
+
endLine: end.line + 1,
|
|
121
|
+
endColumn: end.character + 1,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sourcePropExpression({ sourcePath, objectId, range }) {
|
|
127
|
+
const props = [
|
|
128
|
+
`path: ${JSON.stringify(sourcePath)}`,
|
|
129
|
+
`kind: "tsx-text"`,
|
|
130
|
+
`source: { line: ${range.line}, column: ${range.column}, endLine: ${range.endLine}, endColumn: ${range.endColumn} }`,
|
|
131
|
+
];
|
|
132
|
+
if (objectId) props.splice(2, 0, `objectId: ${JSON.stringify(objectId)}`);
|
|
133
|
+
return `{ ${props.join(", ")} }`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isTextElementName(name, refs) {
|
|
137
|
+
if (ts.isIdentifier(name)) return refs.identifiers.has(name.text);
|
|
138
|
+
if (!ts.isJsxMemberExpression(name)) return false;
|
|
139
|
+
if (name.name.text !== "Text") return false;
|
|
140
|
+
return ts.isIdentifier(name.expression) && refs.namespaces.has(name.expression.text);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hasJsxAttribute(opening, name) {
|
|
144
|
+
return opening.attributes.properties.some((prop) =>
|
|
145
|
+
ts.isJsxAttribute(prop) && prop.name.text === name
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function stringLiteralAttribute(opening, name) {
|
|
150
|
+
const attr = opening.attributes.properties.find((prop) =>
|
|
151
|
+
ts.isJsxAttribute(prop) && prop.name.text === name
|
|
152
|
+
);
|
|
153
|
+
if (!attr || !ts.isJsxAttribute(attr) || !attr.initializer) return undefined;
|
|
154
|
+
if (ts.isStringLiteral(attr.initializer)) return attr.initializer.text;
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function cleanViteId(id) {
|
|
159
|
+
const withoutQuery = String(id ?? "").split("?")[0];
|
|
160
|
+
const fsPath = withoutQuery.startsWith("/@fs/") ? withoutQuery.slice("/@fs".length) : withoutQuery;
|
|
161
|
+
return realpathIfExists(path.resolve(fsPath));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function realpathIfExists(filePath) {
|
|
165
|
+
try {
|
|
166
|
+
return fs.realpathSync.native(filePath);
|
|
167
|
+
} catch {
|
|
168
|
+
return filePath;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isInsidePath(filePath, parentPath) {
|
|
173
|
+
const relative = path.relative(parentPath, filePath);
|
|
174
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
175
|
+
}
|