@open-press/core 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +9 -5
  2. package/engine/cli.mjs +2 -5
  3. package/engine/commands/_shared.mjs +4 -4
  4. package/engine/commands/deploy.mjs +1 -1
  5. package/engine/commands/inspect.mjs +3 -3
  6. package/engine/commands/replace.mjs +1 -1
  7. package/engine/commands/search.mjs +1 -1
  8. package/engine/commands/upgrade.mjs +47 -5
  9. package/engine/commands/validate.mjs +2 -2
  10. package/engine/document-export.mjs +1 -1
  11. package/engine/{chrome-pdf.mjs → output/chrome-pdf.mjs} +1 -2
  12. package/engine/{deploy-sync.mjs → output/deploy-sync.mjs} +2 -2
  13. package/engine/{fonts.mjs → output/fonts.mjs} +1 -1
  14. package/engine/{public-assets.mjs → output/public-assets.mjs} +2 -2
  15. package/engine/{static-server.mjs → output/static-server.mjs} +2 -2
  16. package/engine/react/caption-numbering.mjs +73 -0
  17. package/engine/react/comment-marker.mjs +54 -10
  18. package/engine/react/document-entry.mjs +124 -64
  19. package/engine/react/document-export.mjs +266 -310
  20. package/engine/react/mdx-compile.mjs +214 -3
  21. package/engine/react/measurement-css.mjs +3 -3
  22. package/engine/react/pagination/allocator.mjs +122 -0
  23. package/engine/react/pagination/regions.mjs +81 -0
  24. package/engine/react/pagination.mjs +9 -121
  25. package/engine/react/pipeline/allocate.mjs +248 -0
  26. package/engine/react/pipeline/final-render.mjs +94 -0
  27. package/engine/react/pipeline/frame-measurement.mjs +300 -0
  28. package/engine/react/pipeline/press-tree.mjs +135 -0
  29. package/engine/react/project-asset-endpoint.mjs +2 -2
  30. package/engine/react/{chapter-css.mjs → section-css.mjs} +12 -9
  31. package/engine/react/sources/heading-numbering.mjs +132 -0
  32. package/engine/react/sources/mdx-resolver.mjs +441 -0
  33. package/engine/react/{workspace-discovery.mjs → style-discovery.mjs} +29 -40
  34. package/engine/{config.mjs → runtime/config.mjs} +15 -0
  35. package/engine/{file-utils.mjs → runtime/file-utils.mjs} +1 -1
  36. package/engine/{inspection.mjs → runtime/inspection.mjs} +3 -4
  37. package/engine/{source-text-tools.mjs → runtime/source-text-tools.mjs} +24 -7
  38. package/engine/runtime/source-workspace.mjs +186 -0
  39. package/engine/{validation.mjs → runtime/validation.mjs} +19 -17
  40. package/package.json +5 -2
  41. package/src/openpress/anchorMap.ts +27 -0
  42. package/src/openpress/core/Frame.tsx +80 -0
  43. package/src/openpress/core/FrameContext.tsx +19 -0
  44. package/src/openpress/core/MdxArea.tsx +35 -0
  45. package/src/openpress/core/Press.tsx +34 -0
  46. package/src/openpress/core/index.tsx +34 -15
  47. package/src/openpress/core/primitives.tsx +23 -0
  48. package/src/openpress/core/types.ts +131 -19
  49. package/src/openpress/core/useSource.ts +28 -0
  50. package/src/openpress/manuscript/index.tsx +196 -0
  51. package/src/openpress/mdx/index.ts +88 -0
  52. package/src/openpress/numbering/index.ts +294 -0
  53. package/src/openpress/publicPage.tsx +4 -186
  54. package/src/openpress/reactDocumentMetadata.ts +2 -16
  55. package/src/openpress/types.ts +0 -16
  56. package/src/openpress/workbench.tsx +2 -36
  57. package/src/styles/openpress/responsive.css +0 -14
  58. package/tsconfig.json +4 -1
  59. package/vite.config.ts +10 -3
  60. package/engine/commands/migrate-to-react.mjs +0 -27
  61. package/engine/page-renderer.mjs +0 -217
  62. package/engine/react/migrate-to-react.mjs +0 -355
  63. package/engine/source-workspace.mjs +0 -76
  64. package/src/openpress/core/basePages.tsx +0 -87
  65. package/src/openpress/pagination.ts +0 -845
  66. /package/engine/{chrome-pdf.d.mts → output/chrome-pdf.d.mts} +0 -0
  67. /package/engine/{katex-assets.mjs → output/katex-assets.mjs} +0 -0
  68. /package/engine/{page-block.mjs → output/page-block.mjs} +0 -0
  69. /package/engine/{pdf-media.mjs → output/pdf-media.mjs} +0 -0
  70. /package/engine/{config.d.mts → runtime/config.d.mts} +0 -0
  71. /package/engine/{issue-report.mjs → runtime/issue-report.mjs} +0 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @open-press/core
2
2
 
3
- Framework runtime, CLI engine, and page primitives for [open-press](https://github.com/quan0715/open-press) — an AI-first fixed-layout document workspace.
3
+ Framework runtime, CLI engine, and Press Tree primitives for [open-press](https://github.com/quan0715/open-press) — an AI-first fixed-layout document workspace.
4
4
 
5
5
  Most users do **not** install this package directly. Instead, scaffold a workspace with the CLI:
6
6
 
@@ -20,15 +20,19 @@ npm install @open-press/core
20
20
 
21
21
  ```tsx
22
22
  import {
23
- BasePage,
24
- BaseCoverPage,
25
- BaseTocPage,
26
- BaseBackCoverPage,
23
+ Press,
24
+ Frame,
25
+ MdxArea,
27
26
  BaseFigure,
28
27
  BaseCallout,
29
28
  } from "@open-press/core";
29
+
30
+ import { mdxSource } from "@open-press/core/mdx";
31
+ import { Sections, Toc } from "@open-press/core/manuscript";
30
32
  ```
31
33
 
34
+ `document/index.tsx` default-exports a `<Press>` tree. `Frame` marks fixed-layout pages, `MdxArea` receives measured MDX blocks, and `mdxSource()` declares which MDX files participate in the render pipeline.
35
+
32
36
  The CLI bin (`open-press`) supports dev / build / preview / validate / pdf / deploy / export commands. It requires a workspace with `openpress.config.mjs` and the surrounding framework files (which the scaffolder installs).
33
37
 
34
38
  ## License
package/engine/cli.mjs CHANGED
@@ -6,7 +6,6 @@ import * as doctorCmd from "./commands/doctor.mjs";
6
6
  import * as exportCmd from "./commands/export.mjs";
7
7
  import * as initCmd from "./commands/init.mjs";
8
8
  import * as inspectCmd from "./commands/inspect.mjs";
9
- import * as migrateToReactCmd from "./commands/migrate-to-react.mjs";
10
9
  import * as pdfCmd from "./commands/pdf.mjs";
11
10
  import * as previewCmd from "./commands/preview.mjs";
12
11
  import * as replaceCmd from "./commands/replace.mjs";
@@ -16,13 +15,12 @@ import * as typecheckCmd from "./commands/typecheck.mjs";
16
15
  import * as upgradeCmd from "./commands/upgrade.mjs";
17
16
  import * as validateCmd from "./commands/validate.mjs";
18
17
  import { parseOptions } from "./commands/_shared.mjs";
19
- import { loadConfig } from "./config.mjs";
18
+ import { loadConfig } from "./runtime/config.mjs";
20
19
  import { listStylePackSkills } from "./init.mjs";
21
- import { discoverWorkspace } from "./validation.mjs";
20
+ import { discoverWorkspace } from "./runtime/validation.mjs";
22
21
 
23
22
  const COMMANDS = {
24
23
  init: initCmd,
25
- "migrate-to-react": migrateToReactCmd,
26
24
  validate: validateCmd,
27
25
  inspect: inspectCmd,
28
26
  search: searchCmd,
@@ -79,7 +77,6 @@ async function printHelp() {
79
77
 
80
78
  Commands:
81
79
  init <target> [--skill <name>] [--force]
82
- migrate-to-react [path] [--dry-run] [--force] [--json]
83
80
  validate
84
81
  inspect [--json] [--no-build] [--dry-run]
85
82
  search [path] <query> [--json] [--scope content|all]
@@ -2,14 +2,14 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { printUrlToPdf, stopChildProcess, waitForPrintReady } from "../chrome-pdf.mjs";
6
- import { loadConfig, publicPdfHref } from "../config.mjs";
5
+ import { printUrlToPdf, stopChildProcess, waitForPrintReady } from "../output/chrome-pdf.mjs";
6
+ import { loadConfig, publicPdfHref } from "../runtime/config.mjs";
7
7
  import { exportDocument } from "../document-export.mjs";
8
- import { optimizePdfMediaForStaticRoot } from "../pdf-media.mjs";
8
+ import { optimizePdfMediaForStaticRoot } from "../output/pdf-media.mjs";
9
9
 
10
10
  export const ENGINE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
11
11
  export const CLI_ENTRY = path.join(ENGINE_DIR, "cli.mjs");
12
- export const STATIC_SERVER = path.join(ENGINE_DIR, "static-server.mjs");
12
+ export const STATIC_SERVER = path.join(ENGINE_DIR, "output", "static-server.mjs");
13
13
 
14
14
  export function parseOptions(argv) {
15
15
  const options = {};
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { deploySync } from "../deploy-sync.mjs";
2
+ import { deploySync } from "../output/deploy-sync.mjs";
3
3
  import { CLI_ENTRY, buildReactPdf, formatNodeScriptCommand, runCommand, writePdfStageDeployConfig } from "./_shared.mjs";
4
4
 
5
5
  export async function run({ root, config, options, recurse }) {
@@ -1,5 +1,5 @@
1
- import { inspectWorkspace } from "../inspection.mjs";
2
- import { exitCodeForIssueReport } from "../issue-report.mjs";
1
+ import { inspectWorkspace } from "../runtime/inspection.mjs";
2
+ import { exitCodeForIssueReport } from "../runtime/issue-report.mjs";
3
3
 
4
4
  export async function run({ root, config, options, recurse }) {
5
5
  const host = options.host ?? "127.0.0.1";
@@ -10,7 +10,7 @@ export async function run({ root, config, options, recurse }) {
10
10
  if (!options.noBuild) {
11
11
  console.log("Command: node engine/cli.mjs render . --renderer react");
12
12
  }
13
- console.log(`Command: node engine/static-server.mjs ${config.outputDir} --host ${host} --port ${port} --workspace .`);
13
+ console.log(`Command: node engine/output/static-server.mjs ${config.outputDir} --host ${host} --port ${port} --workspace .`);
14
14
  console.log(`Chrome inspection URL: ${url}`);
15
15
  return 0;
16
16
  }
@@ -1,4 +1,4 @@
1
- import { replaceSourceText } from "../source-text-tools.mjs";
1
+ import { replaceSourceText } from "../runtime/source-text-tools.mjs";
2
2
 
3
3
  export async function run({ config, options }) {
4
4
  const args = replaceArgsFromOptions(options);
@@ -1,4 +1,4 @@
1
- import { searchSourceText } from "../source-text-tools.mjs";
1
+ import { searchSourceText } from "../runtime/source-text-tools.mjs";
2
2
 
3
3
  export async function run({ config, options }) {
4
4
  const query = searchQueryFromOptions(options);
@@ -1,9 +1,15 @@
1
1
  import { existsSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { diagnose } from "./doctor.mjs";
5
5
  import { runCommand } from "./_shared.mjs";
6
6
 
7
+ // Migration notes live in the framework repo, not in scaffolded workspaces.
8
+ // `npx open-press upgrade` fetches the notes for each pending version and
9
+ // caches them under `.openpress/migrations/` so agents can read locally.
10
+ const MIGRATION_REMOTE_BASE = "https://raw.githubusercontent.com/quan0715/open-press/main/docs/migrations";
11
+ const MIGRATION_CACHE_DIR = path.join(".openpress", "migrations");
12
+
7
13
  export async function run({ root, options }) {
8
14
  const dryRun = Boolean(options?.dryRun);
9
15
  const skipSkills = Boolean(options?.noSkills);
@@ -85,7 +91,11 @@ export async function run({ root, options }) {
85
91
  process.stdout.write(" (no migration docs in this version range)\n\n");
86
92
  } else {
87
93
  for (const m of migrationContents) {
88
- process.stdout.write(` ─ ${m.path}\n`);
94
+ if (m.path) {
95
+ process.stdout.write(` ─ ${m.path}${m.fetched ? " (fetched from github)" : ""}\n`);
96
+ } else {
97
+ process.stdout.write(` ─ ${m.version}.md (not found locally or on github — check the repo manually)\n`);
98
+ }
89
99
  }
90
100
  process.stdout.write(
91
101
  "\nAgent: open each file, identify document-level changes, grep document/ for affected patterns, propose edits before applying.\n",
@@ -107,11 +117,43 @@ async function hasCoreDep(root) {
107
117
 
108
118
  async function loadMigrations(root, versions) {
109
119
  const results = [];
120
+ const cacheDir = path.join(root, MIGRATION_CACHE_DIR);
121
+ await mkdir(cacheDir, { recursive: true });
122
+
110
123
  for (const v of versions) {
111
- const p = path.join(root, "docs", "migrations", `${v}.md`);
112
- if (existsSync(p)) {
113
- results.push({ version: v, path: path.relative(root, p) });
124
+ // Framework repo has docs/migrations/ at root — prefer local if present
125
+ // (covers the open-press framework repo itself acting as a workspace).
126
+ const localDocsPath = path.join(root, "docs", "migrations", `${v}.md`);
127
+ if (existsSync(localDocsPath)) {
128
+ results.push({ version: v, path: path.relative(root, localDocsPath), fetched: false });
129
+ continue;
130
+ }
131
+
132
+ // Otherwise fetch from GitHub raw and cache to .openpress/migrations/.
133
+ const cachedPath = path.join(cacheDir, `${v}.md`);
134
+ if (existsSync(cachedPath)) {
135
+ results.push({ version: v, path: path.relative(root, cachedPath), fetched: false });
136
+ continue;
137
+ }
138
+
139
+ const body = await fetchMigration(v);
140
+ if (body) {
141
+ await writeFile(cachedPath, body, "utf8");
142
+ results.push({ version: v, path: path.relative(root, cachedPath), fetched: true });
143
+ } else {
144
+ results.push({ version: v, path: null, fetched: false });
114
145
  }
115
146
  }
116
147
  return results;
117
148
  }
149
+
150
+ async function fetchMigration(version) {
151
+ const url = `${MIGRATION_REMOTE_BASE}/${version}.md`;
152
+ try {
153
+ const res = await fetch(url, { headers: { Accept: "text/plain" } });
154
+ if (!res.ok) return null;
155
+ return await res.text();
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
@@ -1,5 +1,5 @@
1
- import { validateWorkspace } from "../validation.mjs";
2
- import { exitCodeForIssueReport } from "../issue-report.mjs";
1
+ import { validateWorkspace } from "../runtime/validation.mjs";
2
+ import { exitCodeForIssueReport } from "../runtime/issue-report.mjs";
3
3
 
4
4
  export async function run({ root, options }) {
5
5
  const report = await validateWorkspace(root);
@@ -10,6 +10,6 @@ export async function exportDocument(root = ROOT) {
10
10
  if (reactResult) return reactResult;
11
11
 
12
12
  throw new Error(
13
- "React/MDX document entry not found. Expected document/index.tsx; run `node engine/cli.mjs migrate-to-react .` before exporting.",
13
+ "React/MDX document entry not found. Expected document/index.tsx with a Press default export before exporting.",
14
14
  );
15
15
  }
@@ -203,8 +203,7 @@ export async function waitForPrintReady(client) {
203
203
  awaitPromise: true,
204
204
  expression: `Promise.resolve().then(async () => {
205
205
  const root = document.querySelector('[data-openpress-print-document="true"]');
206
- const ready = root?.getAttribute('data-openpress-pagination') === 'ready';
207
- if (!ready) return 0;
206
+ if (!root || root.querySelectorAll('.openpress-html-page').length === 0) return 0;
208
207
 
209
208
  await document.fonts?.ready;
210
209
  await Promise.all(Array.from(document.images).map(async (img) => {
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { loadConfig } from "./config.mjs";
4
- import { copyDirectory } from "./file-utils.mjs";
3
+ import { loadConfig } from "../runtime/config.mjs";
4
+ import { copyDirectory } from "../runtime/file-utils.mjs";
5
5
 
6
6
  export async function deploySync(root, sourceDir, deployDir) {
7
7
  const config = await loadConfig(root);
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { copyDirectory } from "./file-utils.mjs";
3
+ import { copyDirectory } from "../runtime/file-utils.mjs";
4
4
 
5
5
  export async function copyThemeFonts(root, publicOutputDir, config) {
6
6
  const themeDir = config?.paths?.themeDir ?? path.join(path.resolve(root), "theme");
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { loadConfig } from "./config.mjs";
4
- import { copyDirectory, writeComponentsCss, writeContentCss } from "./file-utils.mjs";
3
+ import { loadConfig } from "../runtime/config.mjs";
4
+ import { copyDirectory, writeComponentsCss, writeContentCss } from "../runtime/file-utils.mjs";
5
5
  import { copyThemeFonts } from "./fonts.mjs";
6
6
  import { copyKatexFonts } from "./katex-assets.mjs";
7
7
 
@@ -2,8 +2,8 @@ import fs from "node:fs/promises";
2
2
  import http from "node:http";
3
3
  import path from "node:path";
4
4
  import { spawn } from "node:child_process";
5
- import { loadConfig, publicPdfHref } from "./config.mjs";
6
- import { handleProjectAssetRequest } from "./react/project-asset-endpoint.mjs";
5
+ import { loadConfig, publicPdfHref } from "../runtime/config.mjs";
6
+ import { handleProjectAssetRequest } from "../react/project-asset-endpoint.mjs";
7
7
 
8
8
  const [rootArg = "dist", ...rest] = process.argv.slice(2);
9
9
  const host = valueAfter(rest, "--host") ?? "127.0.0.1";
@@ -0,0 +1,73 @@
1
+ const DEFAULT_CAPTION_NUMBERING = {
2
+ figure: "Figure",
3
+ table: "Table",
4
+ separator: " ",
5
+ };
6
+
7
+ export function normalizeCaptionNumbering(value = {}) {
8
+ const input = value && typeof value === "object" && !Array.isArray(value) ? value : {};
9
+ return {
10
+ figure: stringOption(input.figure, DEFAULT_CAPTION_NUMBERING.figure),
11
+ table: stringOption(input.table, DEFAULT_CAPTION_NUMBERING.table),
12
+ separator: typeof input.separator === "string" ? input.separator : DEFAULT_CAPTION_NUMBERING.separator,
13
+ };
14
+ }
15
+
16
+ export function createCaptionNumberingState() {
17
+ return {
18
+ figure: 0,
19
+ table: 0,
20
+ seenTables: new Set(),
21
+ };
22
+ }
23
+
24
+ export function numberCaptionsInHtml(html, numbering, state = createCaptionNumberingState()) {
25
+ if (!html) return html;
26
+ const options = normalizeCaptionNumbering(numbering);
27
+ let out = String(html);
28
+ out = numberTableCaptions(out, options, state);
29
+ out = numberFigureCaptions(out, options, state);
30
+ return out;
31
+ }
32
+
33
+ function numberTableCaptions(html, options, state) {
34
+ return html.replace(/<table\b([^>]*)>([\s\S]*?<caption\b([^>]*)>)([\s\S]*?)(<\/caption>[\s\S]*?<\/table>)/g, (match, tableAttrs, beforeCaptionText, captionAttrs, captionText, afterCaptionText) => {
35
+ if (captionText.includes("data-openpress-caption-label=")) return match;
36
+ const tableId = attrValue(tableAttrs, "data-openpress-table-id");
37
+ if (tableId && state.seenTables.has(tableId)) return match;
38
+ if (tableId) state.seenTables.add(tableId);
39
+ state.table += 1;
40
+ const label = captionLabel(options.table, state.table, options.separator);
41
+ return `<table${tableAttrs}>${beforeCaptionText}${captionLabelSpan("table", state.table, label)} ${captionText}${afterCaptionText}`;
42
+ });
43
+ }
44
+
45
+ function numberFigureCaptions(html, options, state) {
46
+ return html.replace(/<figure\b([^>]*)>([\s\S]*?<figcaption\b([^>]*)>)([\s\S]*?)(<\/figcaption>[\s\S]*?<\/figure>)/g, (match, figureAttrs, beforeCaptionText, captionAttrs, captionText, afterCaptionText) => {
47
+ if (captionText.includes("data-openpress-caption-label=")) return match;
48
+ state.figure += 1;
49
+ const label = captionLabel(options.figure, state.figure, options.separator);
50
+ return `<figure${figureAttrs}>${beforeCaptionText}${captionLabelSpan("figure", state.figure, label)} ${captionText}${afterCaptionText}`;
51
+ });
52
+ }
53
+
54
+ function captionLabel(noun, number, separator) {
55
+ return `${noun}${separator}${number}`;
56
+ }
57
+
58
+ function captionLabelSpan(kind, number, label) {
59
+ return `<span class="openpress-caption-label" data-openpress-caption-label="${kind}" data-openpress-caption-number="${number}">${escapeHtml(label)}</span>`;
60
+ }
61
+
62
+ function attrValue(attrs, name) {
63
+ const pattern = new RegExp(`${name}=(["'])(.*?)\\1`);
64
+ return attrs.match(pattern)?.[2] ?? "";
65
+ }
66
+
67
+ function stringOption(value, fallback) {
68
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
69
+ }
70
+
71
+ function escapeHtml(value) {
72
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
73
+ }
@@ -1,15 +1,18 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { loadConfig } from "../config.mjs";
5
- import { collectSourceTextFiles } from "../source-text-tools.mjs";
6
-
4
+ import { loadConfig } from "../runtime/config.mjs";
5
+ import { collectSourceTextFiles } from "../runtime/source-text-tools.mjs";
6
+
7
+ // Any `.mdx` or `.tsx` file under `document/` is a legal comment target.
8
+ // The Press Tree allows arbitrary source layouts — `section-folders`,
9
+ // `section-files`, `file-list`, custom `root` paths, etc. — so we no
10
+ // longer hardcode `document/chapters/<slug>/content/*.mdx`. The boundary
11
+ // is "inside the workspace's authored `document/` directory" and "looks
12
+ // like an editable React/MDX source" by extension.
7
13
  const EDITABLE_COMMENT_SOURCE_PATTERNS = [
8
- /^document\/index\.tsx$/,
9
- /^document\/chapters\/[^/]+\/content\/[^/]+\.mdx$/,
10
- /^document\/chapters\/[^/]+\/chapter\.tsx$/,
11
- /^document\/chapters\/[^/]+\/components\/.+\.tsx$/,
12
- /^document\/components\/.+\.tsx$/,
14
+ /^document\/.+\.mdx$/,
15
+ /^document\/.+\.tsx$/,
13
16
  ];
14
17
  const COMMENT_MARKER_RE = /\{\/\*\s*@openpress-comment\b(?<attrs>[^*]*)\*\/\}/g;
15
18
  const COMMENT_LINE_RE = /^\s*\{\/\*\s*@openpress-comment\b[^*]*\*\/\}\s*$/;
@@ -138,8 +141,14 @@ export function assertEditableCommentPath(relativePath) {
138
141
  }
139
142
  }
140
143
 
144
+ // Strict check against workspace-relative paths. Callers walking the
145
+ // workspace (`applyCommentMarker` via `collectSourceTextFiles`) already
146
+ // receive paths with the `document/` prefix and must not have system
147
+ // paths silently mapped into the editable set.
141
148
  export function isEditableCommentPath(relativePath) {
142
- return EDITABLE_COMMENT_SOURCE_PATTERNS.some((pattern) => pattern.test(relativePath));
149
+ if (typeof relativePath !== "string" || !relativePath) return false;
150
+ const trimmed = relativePath.trim().replaceAll("\\", "/").replace(/^\.\//, "");
151
+ return EDITABLE_COMMENT_SOURCE_PATTERNS.some((pattern) => pattern.test(trimmed));
143
152
  }
144
153
 
145
154
  function normalizeEditableSourcePath(value) {
@@ -150,7 +159,42 @@ function normalizeEditableSourcePath(value) {
150
159
  if (path.posix.isAbsolute(normalized) || normalized.includes("\0") || normalized === "." || normalized.startsWith("../")) {
151
160
  throw new Error(`OpenPress comment target path is invalid: ${value}`);
152
161
  }
153
- return path.posix.normalize(normalized);
162
+ const posix = path.posix.normalize(normalized);
163
+ // The Press Tree source resolver emits paths relative to `document/`
164
+ // (e.g. "chapters/01-start/content/01-start.mdx"). The comment marker
165
+ // works in workspace-relative paths (with the `document/` prefix). If
166
+ // the incoming path is documentRoot-relative, prepend `document/`.
167
+ if (!posix.startsWith("document/") && looksDocumentRelative(posix)) {
168
+ return `document/${posix}`;
169
+ }
170
+ return posix;
171
+ }
172
+
173
+ // Identify paths the Press Tree source resolver emits — those are relative
174
+ // to `document/`. Match `.mdx` / `.tsx` files that don't already have the
175
+ // `document/` prefix and don't look like system / engine paths. The check
176
+ // is intentionally tight so we never silently rewrite engine internals
177
+ // (e.g. `src/openpress/...`) into "editable" workspace paths.
178
+ const SYSTEM_PATH_PREFIXES = [
179
+ "document/",
180
+ "src/",
181
+ "engine/",
182
+ "dist/",
183
+ "dist-react/",
184
+ "node_modules/",
185
+ "tests/",
186
+ "public/",
187
+ "packages/",
188
+ ".openpress/",
189
+ ".deploy/",
190
+ ".changeset/",
191
+ ".github/",
192
+ ];
193
+
194
+ function looksDocumentRelative(posixPath) {
195
+ if (!/\.(mdx|tsx)$/.test(posixPath)) return false;
196
+ if (SYSTEM_PATH_PREFIXES.some((prefix) => posixPath.startsWith(prefix))) return false;
197
+ return true;
154
198
  }
155
199
 
156
200
  function normalizeLineNumber(value) {