@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 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
- console.log(`${result.files.length} OpenPress pages exported to PNG`);
167
- return { outDir, files: result.files, pageCount: result.pageCount };
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
- console.log(`OpenPress images: ${path.relative(root, result.outDir)} (${result.files.length} pages)`);
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 pageCount = await waitForReady(client);
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 [index, rect] of rects.entries()) {
199
- const filename = `page-${String(index + 1).padStart(padWidth, "0")}.png`;
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 async function waitForPrintReady(client) {
286
- const deadline = Date.now() + 30000;
287
- while (Date.now() < deadline) {
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: `Promise.resolve().then(async () => {
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 count = Number(result.result?.value ?? 0);
334
- if (count > 0) return count;
335
- await delay(100);
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: [reactRuntimePlugin(), react()],
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
+ }