@open-press/core 1.1.2 → 1.1.3
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/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/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
|
@@ -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
|
+
}
|
|
@@ -93,8 +93,7 @@ export async function applySourceBlockTextEdit({
|
|
|
93
93
|
}) {
|
|
94
94
|
const requestedPath = stringValue(sourcePath);
|
|
95
95
|
if (!requestedPath) throw new Error("Source edit requires a source path.");
|
|
96
|
-
const
|
|
97
|
-
const file = files.find((candidate) => sourceTextPathMatches(candidate.relativePath, requestedPath));
|
|
96
|
+
const file = await findEditableSourceTextFile(config, requestedPath);
|
|
98
97
|
if (!file) throw new Error(`Editable source file not found: ${requestedPath}`);
|
|
99
98
|
|
|
100
99
|
const result = sourceMode
|
|
@@ -120,8 +119,7 @@ export async function applySourceBlockTextEdit({
|
|
|
120
119
|
export async function readSourceBlockText({ config, path: sourcePath, source }) {
|
|
121
120
|
const requestedPath = stringValue(sourcePath);
|
|
122
121
|
if (!requestedPath) throw new Error("Source read requires a source path.");
|
|
123
|
-
const
|
|
124
|
-
const file = files.find((candidate) => sourceTextPathMatches(candidate.relativePath, requestedPath));
|
|
122
|
+
const file = await findEditableSourceTextFile(config, requestedPath);
|
|
125
123
|
if (!file) throw new Error(`Editable source file not found: ${requestedPath}`);
|
|
126
124
|
return {
|
|
127
125
|
path: file.relativePath,
|
|
@@ -205,6 +203,13 @@ export function applySourceBlockTextEditToText(documentText, {
|
|
|
205
203
|
name,
|
|
206
204
|
});
|
|
207
205
|
}
|
|
206
|
+
if (kind === "object-text") {
|
|
207
|
+
return applySourceBlockObjectTextEditToText(documentText, {
|
|
208
|
+
source,
|
|
209
|
+
text,
|
|
210
|
+
blockId,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
208
213
|
if (kind === "element" && name === "pre") {
|
|
209
214
|
return applySourceBlockCodeEditToText(documentText, {
|
|
210
215
|
source,
|
|
@@ -265,6 +270,48 @@ export function applySourceBlockTextEditToText(documentText, {
|
|
|
265
270
|
};
|
|
266
271
|
}
|
|
267
272
|
|
|
273
|
+
function applySourceBlockObjectTextEditToText(documentText, {
|
|
274
|
+
source,
|
|
275
|
+
text,
|
|
276
|
+
blockId,
|
|
277
|
+
} = {}) {
|
|
278
|
+
const sourceRange = normalizeSourceRange(source);
|
|
279
|
+
const replacementText = normalizeEditedText(text);
|
|
280
|
+
const lines = splitTextLines(documentText);
|
|
281
|
+
const startIndex = sourceRange.line - 1;
|
|
282
|
+
const endIndex = sourceRange.endLine - 1;
|
|
283
|
+
if (!lines[startIndex]) throw new Error(`Source edit line ${sourceRange.line} is outside the source file.`);
|
|
284
|
+
if (!lines[endIndex]) throw new Error(`Source edit end line ${sourceRange.endLine} is outside the source file.`);
|
|
285
|
+
|
|
286
|
+
const selectedLines = lines.slice(startIndex, endIndex + 1);
|
|
287
|
+
const before = selectedLines.map((line) => line.line).join("\n");
|
|
288
|
+
const after = replaceSourceRangeText(before, sourceRange, replacementText);
|
|
289
|
+
const replacementLines = after.split("\n");
|
|
290
|
+
const ending = selectedLines[selectedLines.length - 1].ending;
|
|
291
|
+
const nextLines = [
|
|
292
|
+
...lines.slice(0, startIndex),
|
|
293
|
+
...replacementLines.map((line, index) => ({
|
|
294
|
+
line,
|
|
295
|
+
ending: index === replacementLines.length - 1 ? ending : "\n",
|
|
296
|
+
})),
|
|
297
|
+
...lines.slice(endIndex + 1),
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
text: joinTextLines(nextLines),
|
|
302
|
+
edit: {
|
|
303
|
+
blockId,
|
|
304
|
+
line: sourceRange.line,
|
|
305
|
+
column: sourceRange.column,
|
|
306
|
+
endLine: sourceRange.endLine,
|
|
307
|
+
endColumn: sourceRange.endColumn,
|
|
308
|
+
before,
|
|
309
|
+
after,
|
|
310
|
+
text: replacementText,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
268
315
|
function applySourceBlockCodeEditToText(documentText, {
|
|
269
316
|
source,
|
|
270
317
|
text,
|
|
@@ -722,6 +769,15 @@ function replaceTableCellContent(cell, replacementText) {
|
|
|
722
769
|
return `${leading}${replacementText}${trailing}`;
|
|
723
770
|
}
|
|
724
771
|
|
|
772
|
+
function replaceSourceRangeText(sourceText, sourceRange, replacementText) {
|
|
773
|
+
const lines = sourceText.split("\n");
|
|
774
|
+
const firstLine = lines[0] ?? "";
|
|
775
|
+
const lastLine = lines[lines.length - 1] ?? "";
|
|
776
|
+
const prefix = firstLine.slice(0, Math.max(0, sourceRange.column - 1));
|
|
777
|
+
const suffix = lastLine.slice(Math.max(0, sourceRange.endColumn - 1));
|
|
778
|
+
return `${prefix}${replacementText}${suffix}`;
|
|
779
|
+
}
|
|
780
|
+
|
|
725
781
|
function markdownTextPrefix(line, { kind, name }) {
|
|
726
782
|
if (kind === "list-item") return line.match(/^(\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?)/)?.[1] ?? "- ";
|
|
727
783
|
if (name && /^h[1-6]$/.test(name)) return line.match(/^(\s{0,3}#{1,6}\s+)/)?.[1] ?? `${"#".repeat(Number(name.slice(1)))} `;
|
|
@@ -791,8 +847,8 @@ function applySourceBlockComponentCaptionEditToText(documentText, {
|
|
|
791
847
|
}
|
|
792
848
|
|
|
793
849
|
function assertEditableComponentCaption({ name, blockId }) {
|
|
794
|
-
if (name === "
|
|
795
|
-
throw new Error(`
|
|
850
|
+
if (typeof name === "string" && /^[A-Z][A-Za-z0-9_$]*$/.test(name)) return;
|
|
851
|
+
throw new Error(`Component caption edits require a named React component${blockId ? `: ${blockId}` : ""}.`);
|
|
796
852
|
}
|
|
797
853
|
|
|
798
854
|
function replaceComponentCaptionProp(sourceText, replacementText) {
|
|
@@ -820,6 +876,15 @@ function sourceTextPathMatches(candidatePath, requestedPath) {
|
|
|
820
876
|
return normalizeSourceTextPath(candidatePath) === normalizeSourceTextPath(requestedPath);
|
|
821
877
|
}
|
|
822
878
|
|
|
879
|
+
async function findEditableSourceTextFile(config, requestedPath) {
|
|
880
|
+
const contentFiles = await collectSourceTextFiles(config, { scope: "content" });
|
|
881
|
+
const contentMatch = contentFiles.find((candidate) => sourceTextPathMatches(candidate.relativePath, requestedPath));
|
|
882
|
+
if (contentMatch) return contentMatch;
|
|
883
|
+
|
|
884
|
+
const allFiles = await collectSourceTextFiles(config, { scope: "all" });
|
|
885
|
+
return allFiles.find((candidate) => sourceTextPathMatches(candidate.relativePath, requestedPath));
|
|
886
|
+
}
|
|
887
|
+
|
|
823
888
|
function normalizeSourceTextPath(value) {
|
|
824
889
|
return String(value ?? "")
|
|
825
890
|
.replaceAll("\\", "/")
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from "react";
|
|
2
|
-
import { OpenPressRuntime } from "./OpenPressRuntime";
|
|
2
|
+
import { OpenPressRuntime, type OpenPressRuntimeMode } from "./OpenPressRuntime";
|
|
3
3
|
import { WorkspaceGalleryPage } from "./WorkspaceGalleryPage";
|
|
4
4
|
import { isLocalWorkspaceHost } from "../shared";
|
|
5
5
|
import type {
|
|
@@ -28,9 +28,15 @@ type LoadState =
|
|
|
28
28
|
// or for the root entry of a multi-Press workspace. Otherwise the
|
|
29
29
|
// active press's slug — used by refresh/back/forward to re-resolve.
|
|
30
30
|
activeSlug: string;
|
|
31
|
+
runtimeMode: OpenPressRuntimeMode;
|
|
31
32
|
}
|
|
32
33
|
| { status: "error"; message: string };
|
|
33
34
|
|
|
35
|
+
interface WorkspaceRoute {
|
|
36
|
+
slug: string;
|
|
37
|
+
mode: OpenPressRuntimeMode;
|
|
38
|
+
}
|
|
39
|
+
|
|
34
40
|
interface DeployConfig {
|
|
35
41
|
pdf?: string;
|
|
36
42
|
deployed_at?: string;
|
|
@@ -63,23 +69,30 @@ export function OpenPressApp() {
|
|
|
63
69
|
|
|
64
70
|
// Single resolution function — same code path for "boot from URL",
|
|
65
71
|
// "click gallery card", and "browser back button". Given a manifest
|
|
66
|
-
// +
|
|
67
|
-
const
|
|
72
|
+
// + route, decides whether to render gallery or load a press.
|
|
73
|
+
const resolveFromRoute = useCallback(async (
|
|
68
74
|
manifest: WorkspaceManifest | null,
|
|
69
|
-
|
|
75
|
+
route: WorkspaceRoute,
|
|
70
76
|
deploymentInfo: DeploymentInfo,
|
|
71
77
|
) => {
|
|
72
78
|
// No manifest (legacy deploy): always load /openpress/document.json.
|
|
73
79
|
if (!manifest || manifest.presses.length === 0) {
|
|
74
80
|
const document = await loadReaderDocument("/openpress/document.json");
|
|
75
|
-
setState({
|
|
81
|
+
setState({
|
|
82
|
+
status: "ready",
|
|
83
|
+
document,
|
|
84
|
+
deploymentInfo,
|
|
85
|
+
manifest,
|
|
86
|
+
activeSlug: "",
|
|
87
|
+
runtimeMode: resolveRuntimeMode(document, route.mode),
|
|
88
|
+
});
|
|
76
89
|
return;
|
|
77
90
|
}
|
|
78
91
|
|
|
79
92
|
// Empty slug + multi-Press: show gallery. Empty slug + single-Press:
|
|
80
93
|
// load the only press. Same expression handles both — array length
|
|
81
94
|
// is the only thing that matters.
|
|
82
|
-
const normalizedSlug = normalizeSlug(slug);
|
|
95
|
+
const normalizedSlug = normalizeSlug(route.slug);
|
|
83
96
|
if (!normalizedSlug && manifestHasMultiplePresses(manifest)) {
|
|
84
97
|
setState({ status: "gallery", manifest, deploymentInfo });
|
|
85
98
|
return;
|
|
@@ -96,7 +109,14 @@ export function OpenPressApp() {
|
|
|
96
109
|
return;
|
|
97
110
|
}
|
|
98
111
|
const document = await loadReaderDocument(press.documentUrl);
|
|
99
|
-
setState({
|
|
112
|
+
setState({
|
|
113
|
+
status: "ready",
|
|
114
|
+
document,
|
|
115
|
+
deploymentInfo,
|
|
116
|
+
manifest,
|
|
117
|
+
activeSlug: press.slug,
|
|
118
|
+
runtimeMode: resolveRuntimeMode(document, route.mode),
|
|
119
|
+
});
|
|
100
120
|
}, []);
|
|
101
121
|
|
|
102
122
|
const refreshDocument = useCallback(async () => {
|
|
@@ -112,12 +132,12 @@ export function OpenPressApp() {
|
|
|
112
132
|
});
|
|
113
133
|
}, [state]);
|
|
114
134
|
|
|
115
|
-
// Gallery click → pushState + load. Bypasses
|
|
135
|
+
// Gallery click → pushState + load. Bypasses resolveFromRoute's
|
|
116
136
|
// "empty slug + multi-Press → gallery" branch: an explicit click on
|
|
117
137
|
// the unslugged root Press must enter it, not bounce back to gallery.
|
|
118
138
|
const enterPress = useCallback(async (press: WorkspaceManifestPress) => {
|
|
119
139
|
if (state.status !== "gallery") return;
|
|
120
|
-
|
|
140
|
+
pushPressRoute(press.slug, "preview");
|
|
121
141
|
setState({ status: "loading" });
|
|
122
142
|
try {
|
|
123
143
|
const document = await loadReaderDocument(press.documentUrl);
|
|
@@ -127,6 +147,7 @@ export function OpenPressApp() {
|
|
|
127
147
|
deploymentInfo: state.deploymentInfo,
|
|
128
148
|
manifest: state.manifest,
|
|
129
149
|
activeSlug: press.slug,
|
|
150
|
+
runtimeMode: "preview",
|
|
130
151
|
});
|
|
131
152
|
} catch (error) {
|
|
132
153
|
setState({
|
|
@@ -147,7 +168,7 @@ export function OpenPressApp() {
|
|
|
147
168
|
loadDeploymentInfo(),
|
|
148
169
|
]);
|
|
149
170
|
if (cancelled) return;
|
|
150
|
-
await
|
|
171
|
+
await resolveFromRoute(manifest, currentRouteFromLocation(), deploymentInfo);
|
|
151
172
|
} catch (error) {
|
|
152
173
|
if (!cancelled) {
|
|
153
174
|
setState({
|
|
@@ -162,7 +183,7 @@ export function OpenPressApp() {
|
|
|
162
183
|
return () => {
|
|
163
184
|
cancelled = true;
|
|
164
185
|
};
|
|
165
|
-
}, [
|
|
186
|
+
}, [resolveFromRoute]);
|
|
166
187
|
|
|
167
188
|
// Back / forward button — re-resolve from the new URL.
|
|
168
189
|
useEffect(() => {
|
|
@@ -176,11 +197,11 @@ export function OpenPressApp() {
|
|
|
176
197
|
const deploymentInfo = state.status === "gallery" || state.status === "ready"
|
|
177
198
|
? state.deploymentInfo
|
|
178
199
|
: offlineDeploymentInfo;
|
|
179
|
-
void
|
|
200
|
+
void resolveFromRoute(manifest, currentRouteFromLocation(), deploymentInfo);
|
|
180
201
|
}
|
|
181
202
|
window.addEventListener("popstate", onPopState);
|
|
182
203
|
return () => window.removeEventListener("popstate", onPopState);
|
|
183
|
-
}, [state,
|
|
204
|
+
}, [state, resolveFromRoute]);
|
|
184
205
|
|
|
185
206
|
if (state.status === "loading") return <LoadingScreen />;
|
|
186
207
|
|
|
@@ -197,7 +218,7 @@ export function OpenPressApp() {
|
|
|
197
218
|
const backToWorkspace = state.manifest && manifestHasMultiplePresses(state.manifest)
|
|
198
219
|
? () => {
|
|
199
220
|
if (state.status !== "ready" || !state.manifest) return;
|
|
200
|
-
|
|
221
|
+
pushPressRoute("", "preview");
|
|
201
222
|
setState({
|
|
202
223
|
status: "gallery",
|
|
203
224
|
manifest: state.manifest,
|
|
@@ -206,49 +227,97 @@ export function OpenPressApp() {
|
|
|
206
227
|
}
|
|
207
228
|
: undefined;
|
|
208
229
|
|
|
230
|
+
const presentationSlug = state.activeSlug || currentRouteFromLocation().slug;
|
|
231
|
+
const openPresentation = state.document.meta.type === "slides" && presentationSlug
|
|
232
|
+
? (pageIndex: number) => {
|
|
233
|
+
openPressRoute(presentationSlug, "present", pageIndex, { fullscreen: true });
|
|
234
|
+
}
|
|
235
|
+
: undefined;
|
|
236
|
+
|
|
237
|
+
const exitPresentation = state.document.meta.type === "slides"
|
|
238
|
+
? (pageIndex: number) => {
|
|
239
|
+
if (state.status !== "ready") return;
|
|
240
|
+
const slug = state.activeSlug || currentRouteFromLocation().slug;
|
|
241
|
+
if (slug) pushPressRoute(slug, "preview", pageIndex);
|
|
242
|
+
setState((latest) => latest.status === "ready"
|
|
243
|
+
? { ...latest, runtimeMode: "preview" }
|
|
244
|
+
: latest);
|
|
245
|
+
}
|
|
246
|
+
: undefined;
|
|
247
|
+
|
|
209
248
|
return (
|
|
210
249
|
<OpenPressRuntime
|
|
211
250
|
document={state.document}
|
|
251
|
+
runtimeMode={state.runtimeMode}
|
|
212
252
|
deploymentInfo={state.deploymentInfo}
|
|
213
253
|
onDocumentRefresh={refreshDocument}
|
|
254
|
+
onOpenPresentation={openPresentation}
|
|
255
|
+
onExitPresentation={exitPresentation}
|
|
214
256
|
onBackToWorkspace={backToWorkspace}
|
|
215
257
|
/>
|
|
216
258
|
);
|
|
217
259
|
}
|
|
218
260
|
|
|
219
|
-
function
|
|
220
|
-
if (typeof window === "undefined") return "";
|
|
221
|
-
return
|
|
261
|
+
function currentRouteFromLocation(): WorkspaceRoute {
|
|
262
|
+
if (typeof window === "undefined") return { slug: "", mode: "preview" };
|
|
263
|
+
return routeFromWorkspacePathname(window.location.pathname);
|
|
222
264
|
}
|
|
223
265
|
|
|
224
266
|
function normalizeSlug(raw: string): string {
|
|
225
267
|
return raw.replace(/^\/+|\/+$/g, "");
|
|
226
268
|
}
|
|
227
269
|
|
|
228
|
-
function
|
|
270
|
+
function routeFromWorkspacePathname(pathname: string): WorkspaceRoute {
|
|
229
271
|
const normalized = normalizeSlug(pathname);
|
|
230
|
-
if (!normalized || normalized === "workspace") return "";
|
|
272
|
+
if (!normalized || normalized === "workspace") return { slug: "", mode: "preview" };
|
|
231
273
|
|
|
232
274
|
const segments = normalized.split("/").filter(Boolean);
|
|
233
|
-
if (segments.length === 2 && segments[1] === "preview") {
|
|
234
|
-
return segments[0] ?? "";
|
|
275
|
+
if (segments.length === 2 && (segments[1] === "preview" || segments[1] === "present")) {
|
|
276
|
+
return { slug: segments[0] ?? "", mode: segments[1] };
|
|
235
277
|
}
|
|
236
278
|
|
|
237
279
|
// Legacy static/public route compatibility. New workspace navigation
|
|
238
280
|
// writes /workspace and /<press-slug>/preview.
|
|
239
|
-
return normalized;
|
|
281
|
+
return { slug: normalized, mode: "preview" };
|
|
240
282
|
}
|
|
241
283
|
|
|
242
|
-
function
|
|
284
|
+
function pushPressRoute(slug: string, mode: OpenPressRuntimeMode, pageIndex?: number) {
|
|
243
285
|
if (typeof window === "undefined") return;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const pathname = slug ? `/${normalizeSlug(slug)}/preview` : "/workspace";
|
|
247
|
-
const target = pathname;
|
|
248
|
-
if (window.location.pathname === pathname) return;
|
|
286
|
+
const target = buildPressRoute(slug, mode, pageIndex);
|
|
287
|
+
if (`${window.location.pathname}${window.location.search}${window.location.hash}` === target) return;
|
|
249
288
|
window.history.pushState({}, "", target);
|
|
250
289
|
}
|
|
251
290
|
|
|
291
|
+
function openPressRoute(
|
|
292
|
+
slug: string,
|
|
293
|
+
mode: OpenPressRuntimeMode,
|
|
294
|
+
pageIndex?: number,
|
|
295
|
+
options: { fullscreen?: boolean } = {},
|
|
296
|
+
) {
|
|
297
|
+
if (typeof window === "undefined") return;
|
|
298
|
+
window.open(buildPressRoute(slug, mode, pageIndex, options), "_blank", "noopener,noreferrer");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function buildPressRoute(
|
|
302
|
+
slug: string,
|
|
303
|
+
mode: OpenPressRuntimeMode,
|
|
304
|
+
pageIndex?: number,
|
|
305
|
+
options: { fullscreen?: boolean } = {},
|
|
306
|
+
) {
|
|
307
|
+
const normalizedSlug = normalizeSlug(slug);
|
|
308
|
+
const pathname = normalizedSlug ? `/${normalizedSlug}/${mode}` : "/workspace";
|
|
309
|
+
const search = mode === "present" && options.fullscreen ? "?fullscreen=1" : "";
|
|
310
|
+
const pageHash = typeof pageIndex === "number"
|
|
311
|
+
? `#page-${String(pageIndex + 1).padStart(2, "0")}`
|
|
312
|
+
: "";
|
|
313
|
+
return `${pathname}${search}${pageHash}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function resolveRuntimeMode(document: ReaderDocument, requestedMode: OpenPressRuntimeMode): OpenPressRuntimeMode {
|
|
317
|
+
if (requestedMode === "present" && document.meta.type === "slides") return "present";
|
|
318
|
+
return "preview";
|
|
319
|
+
}
|
|
320
|
+
|
|
252
321
|
async function loadWorkspaceManifest(): Promise<WorkspaceManifest | null> {
|
|
253
322
|
// Optional — older deployments don't ship workspace.json. The reader
|
|
254
323
|
// falls back to /openpress/document.json directly when missing, which
|