@open-press/core 0.7.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.
@@ -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
+ }
@@ -92,6 +92,21 @@ export async function exportReactDocument(root = ".", { syncAssets = true } = {}
92
92
  blockHeights: measurement.blockHeights,
93
93
  sources,
94
94
  });
95
+ if (process.env.OPENPRESS_DEBUG_ALLOC) {
96
+ const sample = measurement.mdxAreas
97
+ .slice(0, 5)
98
+ .map((a) => `${a.frameKey}#${a.indexInFrame} cap=${a.capacity.toFixed(0)} raw=${(a.rawHeight ?? 0).toFixed(0)}`);
99
+ const blocks = measurement.blockHeights
100
+ .slice(0, 8)
101
+ .map((b) => `${b.id} h=${b.height.toFixed(0)}`);
102
+ process.stderr.write(`[allocator iter ${iteration}]\n`);
103
+ process.stderr.write(` mdxAreas[0..4]: ${sample.join(" | ")}\n`);
104
+ process.stderr.write(` blocks[0..7]: ${blocks.join(" | ")}\n`);
105
+ process.stderr.write(` hints: ${JSON.stringify(alloc.hints.totalPagesPerChain)}\n`);
106
+ if (alloc.warnings.length > 0) {
107
+ process.stderr.write(` warnings: ${JSON.stringify(alloc.warnings)}\n`);
108
+ }
109
+ }
95
110
  if (hintsEqual(hints, alloc.hints)) {
96
111
  allocation = alloc.allocation;
97
112
  warnings = alloc.warnings;
@@ -99,6 +99,16 @@ export function rehypeBlockIds(options = {}) {
99
99
  includeBlockIds,
100
100
  });
101
101
  }
102
+ if (block.name === "ul" || block.name === "ol") {
103
+ return applyListItemBlocks({
104
+ node,
105
+ id,
106
+ blocks,
107
+ filePath,
108
+ chapterSlug,
109
+ includeBlockIds,
110
+ });
111
+ }
102
112
  if (includeBlockIds && !includeBlockIds.has(id)) return false;
103
113
 
104
114
  setDataAttribute(node, "data-openpress-block-id", id);
@@ -314,6 +324,87 @@ function blockInfo(node) {
314
324
  return null;
315
325
  }
316
326
 
327
+ function applyListItemBlocks({
328
+ node,
329
+ id,
330
+ blocks,
331
+ filePath,
332
+ chapterSlug,
333
+ includeBlockIds,
334
+ }) {
335
+ const items = listItems(node);
336
+ if (items.length === 0) {
337
+ if (includeBlockIds && !includeBlockIds.has(id)) return false;
338
+ setDataAttribute(node, "data-openpress-block-id", id);
339
+ blocks.push({
340
+ id,
341
+ kind: "element",
342
+ name: node.tagName,
343
+ text: textContent(node).trim() || undefined,
344
+ filePath,
345
+ chapterSlug,
346
+ source: sourcePosition(node.position),
347
+ });
348
+ return "skip";
349
+ }
350
+
351
+ const itemRecords = items.map((item, index) => ({
352
+ id: `${id}-i${index}`,
353
+ node: item,
354
+ index,
355
+ }));
356
+ const selected = includeBlockIds
357
+ ? itemRecords.filter((item) => includeBlockIds.has(item.id))
358
+ : itemRecords;
359
+ if (selected.length === 0) return false;
360
+
361
+ setDataAttribute(node, "data-openpress-list-id", id);
362
+
363
+ // For ordered lists, continuation pages must keep numbering picking up
364
+ // from the first surviving item. `start` is the 1-based number of the
365
+ // first `<li>` rendered, so if the original list had `start="5"` and we
366
+ // dropped the first three items, continuation starts at 5 + 3 = 8.
367
+ if (node.tagName === "ol" && selected[0]?.index > 0) {
368
+ const baseStart = Number(node.properties?.start ?? 1);
369
+ const continuationStart = baseStart + selected[0].index;
370
+ node.properties = { ...node.properties, start: continuationStart };
371
+ }
372
+
373
+ const selectedNodes = new Set(selected.map((item) => item.node));
374
+ pruneUnselectedListItems(node, new Set(itemRecords.map((item) => item.node)), selectedNodes);
375
+
376
+ for (const item of selected) {
377
+ setDataAttribute(item.node, "data-openpress-block-id", item.id);
378
+ blocks.push({
379
+ id: item.id,
380
+ kind: "list-item",
381
+ name: "list-item",
382
+ text: textContent(item.node).trim() || undefined,
383
+ filePath,
384
+ chapterSlug,
385
+ listId: id,
386
+ listTag: node.tagName,
387
+ itemIndex: item.index,
388
+ source: sourcePosition(item.node.position ?? node.position),
389
+ });
390
+ }
391
+ return "skip";
392
+ }
393
+
394
+ function listItems(list) {
395
+ if (list?.type !== "element") return [];
396
+ if (list.tagName !== "ul" && list.tagName !== "ol") return [];
397
+ return (list.children ?? []).filter((child) => child?.type === "element" && child.tagName === "li");
398
+ }
399
+
400
+ function pruneUnselectedListItems(node, itemNodes, selectedNodes) {
401
+ if (!Array.isArray(node?.children)) return;
402
+ node.children = node.children.filter((child) => {
403
+ if (!itemNodes.has(child)) return true;
404
+ return selectedNodes.has(child);
405
+ });
406
+ }
407
+
317
408
  function tableBodyRows(table) {
318
409
  if (table?.type !== "element" || table.tagName !== "table") return [];
319
410
  const rows = [];
@@ -135,15 +135,30 @@ async function runChromiumMeasurement(html, viewport) {
135
135
  try {
136
136
  const page = await browser.newPage({ viewport });
137
137
  await page.setContent(html, { waitUntil: "load" });
138
+ // Match the print-ready settle: fonts first (font metrics affect image
139
+ // alt-text fallback boxes), then await every image's `complete` AND
140
+ // `decode()` so intrinsic sizes are committed before layout, then two
141
+ // animation frames so the chromium layout pass observes the final box
142
+ // model. Without this, `getBoundingClientRect()` on figures that hold
143
+ // images can race the decode and return collapsed heights, causing the
144
+ // allocator to pack too many blocks per page.
138
145
  await page.evaluate(async () => {
139
- await Promise.all(Array.from(document.images).map((img) => {
140
- if (img.complete) return Promise.resolve();
141
- return new Promise((resolve) => {
142
- img.addEventListener("load", resolve, { once: true });
143
- img.addEventListener("error", resolve, { once: true });
144
- });
145
- }));
146
146
  if (document.fonts?.ready) await document.fonts.ready;
147
+ await Promise.all(Array.from(document.images).map(async (img) => {
148
+ if (!img.complete) {
149
+ await new Promise((resolve) => {
150
+ const settle = () => {
151
+ img.removeEventListener("load", settle);
152
+ img.removeEventListener("error", settle);
153
+ resolve(undefined);
154
+ };
155
+ img.addEventListener("load", settle, { once: true });
156
+ img.addEventListener("error", settle, { once: true });
157
+ });
158
+ }
159
+ await img.decode?.().catch(() => undefined);
160
+ }));
161
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
147
162
  });
148
163
 
149
164
  const mdxAreas = await page.evaluate((safety) => {
@@ -232,13 +247,27 @@ async function inlineMeasurementMediaUrls(html, mediaDir) {
232
247
  if (!mediaDir || !html) return html;
233
248
  let out = String(html);
234
249
  const matches = new Set();
235
- for (const match of out.matchAll(/\/openpress\/media\/([^"')\s>]+)/g)) {
236
- matches.add(match[1]);
250
+ for (const match of out.matchAll(/\bsrc=(['"])([^\1]*?)\1/g)) {
251
+ const src = match[2];
252
+ if (!src) continue;
253
+ if (src.startsWith('/openpress/media/')) {
254
+ matches.add(src.slice('/openpress/media/'.length));
255
+ continue;
256
+ }
257
+ if (src.startsWith('media/')) {
258
+ matches.add(src.slice('media/'.length));
259
+ continue;
260
+ }
261
+ if (src.startsWith('./media/')) {
262
+ matches.add(src.slice('./media/'.length));
263
+ }
237
264
  }
238
265
  for (const rawName of matches) {
239
266
  const dataUrl = await mediaDataUrl(mediaDir, rawName);
240
267
  if (!dataUrl) continue;
241
268
  out = out.replaceAll(`/openpress/media/${rawName}`, dataUrl);
269
+ out = out.replaceAll(`media/${rawName}`, dataUrl);
270
+ out = out.replaceAll(`./media/${rawName}`, dataUrl);
242
271
  }
243
272
  return out;
244
273
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-press/core",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "description": "open-press core — runtime primitives, CLI, and render pipeline for AI-first fixed-layout documents.",
6
6
  "license": "MIT",