@open-press/core 0.6.0 → 0.7.0

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 (70) 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/validate.mjs +2 -2
  9. package/engine/document-export.mjs +1 -1
  10. package/engine/{chrome-pdf.mjs → output/chrome-pdf.mjs} +1 -2
  11. package/engine/{deploy-sync.mjs → output/deploy-sync.mjs} +2 -2
  12. package/engine/{fonts.mjs → output/fonts.mjs} +1 -1
  13. package/engine/{public-assets.mjs → output/public-assets.mjs} +2 -2
  14. package/engine/{static-server.mjs → output/static-server.mjs} +2 -2
  15. package/engine/react/caption-numbering.mjs +73 -0
  16. package/engine/react/comment-marker.mjs +54 -10
  17. package/engine/react/document-entry.mjs +124 -64
  18. package/engine/react/document-export.mjs +252 -311
  19. package/engine/react/mdx-compile.mjs +123 -3
  20. package/engine/react/measurement-css.mjs +3 -3
  21. package/engine/react/pagination/allocator.mjs +122 -0
  22. package/engine/react/pagination/regions.mjs +81 -0
  23. package/engine/react/pagination.mjs +9 -121
  24. package/engine/react/pipeline/allocate.mjs +248 -0
  25. package/engine/react/pipeline/final-render.mjs +94 -0
  26. package/engine/react/pipeline/frame-measurement.mjs +271 -0
  27. package/engine/react/pipeline/press-tree.mjs +135 -0
  28. package/engine/react/project-asset-endpoint.mjs +2 -2
  29. package/engine/react/{chapter-css.mjs → section-css.mjs} +12 -9
  30. package/engine/react/sources/heading-numbering.mjs +132 -0
  31. package/engine/react/sources/mdx-resolver.mjs +441 -0
  32. package/engine/react/{workspace-discovery.mjs → style-discovery.mjs} +29 -40
  33. package/engine/{config.mjs → runtime/config.mjs} +15 -0
  34. package/engine/{file-utils.mjs → runtime/file-utils.mjs} +1 -1
  35. package/engine/{inspection.mjs → runtime/inspection.mjs} +3 -4
  36. package/engine/{source-text-tools.mjs → runtime/source-text-tools.mjs} +24 -7
  37. package/engine/runtime/source-workspace.mjs +186 -0
  38. package/engine/{validation.mjs → runtime/validation.mjs} +19 -17
  39. package/package.json +5 -2
  40. package/src/openpress/anchorMap.ts +27 -0
  41. package/src/openpress/core/Frame.tsx +80 -0
  42. package/src/openpress/core/FrameContext.tsx +19 -0
  43. package/src/openpress/core/MdxArea.tsx +35 -0
  44. package/src/openpress/core/Press.tsx +34 -0
  45. package/src/openpress/core/index.tsx +34 -15
  46. package/src/openpress/core/primitives.tsx +23 -0
  47. package/src/openpress/core/types.ts +131 -19
  48. package/src/openpress/core/useSource.ts +28 -0
  49. package/src/openpress/manuscript/index.tsx +196 -0
  50. package/src/openpress/mdx/index.ts +88 -0
  51. package/src/openpress/numbering/index.ts +294 -0
  52. package/src/openpress/publicPage.tsx +4 -186
  53. package/src/openpress/reactDocumentMetadata.ts +2 -16
  54. package/src/openpress/types.ts +0 -16
  55. package/src/openpress/workbench.tsx +2 -36
  56. package/src/styles/openpress/responsive.css +0 -14
  57. package/tsconfig.json +4 -1
  58. package/vite.config.ts +10 -3
  59. package/engine/commands/migrate-to-react.mjs +0 -27
  60. package/engine/page-renderer.mjs +0 -217
  61. package/engine/react/migrate-to-react.mjs +0 -355
  62. package/engine/source-workspace.mjs +0 -76
  63. package/src/openpress/core/basePages.tsx +0 -87
  64. package/src/openpress/pagination.ts +0 -845
  65. /package/engine/{chrome-pdf.d.mts → output/chrome-pdf.d.mts} +0 -0
  66. /package/engine/{katex-assets.mjs → output/katex-assets.mjs} +0 -0
  67. /package/engine/{page-block.mjs → output/page-block.mjs} +0 -0
  68. /package/engine/{pdf-media.mjs → output/pdf-media.mjs} +0 -0
  69. /package/engine/{config.d.mts → runtime/config.d.mts} +0 -0
  70. /package/engine/{issue-report.mjs → runtime/issue-report.mjs} +0 -0
@@ -0,0 +1,135 @@
1
+ // Layer 2 — Press Tree Expansion.
2
+ //
3
+ // SSR-renders the user's Press tree with a PressContext provider that
4
+ // supplies resolved sources and (optionally) allocation hints. Output:
5
+ // - rendered HTML (used by Layer 3 for measurement and Layer 5 for final)
6
+ // - extracted frame metadata (frameKey, role, chrome, sequence position)
7
+ // - per-frame MdxArea slots (chainId, sequence index within frame)
8
+ //
9
+ // Frames are discovered by parsing the rendered HTML for elements with the
10
+ // `data-openpress-frame-key` attribute. This works because <Frame> renders
11
+ // to a deterministic `<section>` with that attribute set.
12
+
13
+ import React from "react";
14
+ import { renderToStaticMarkup } from "react-dom/server";
15
+
16
+ const FRAME_OPEN_RE = /<section\b([^>]*)\bdata-openpress-frame-key="([^"]+)"([^>]*)>/g;
17
+ const ATTR_RE = (name) => new RegExp(`\\b${name}="([^"]*)"`);
18
+
19
+ /**
20
+ * Render the Press tree and extract frame structure.
21
+ *
22
+ * @param {object} opts
23
+ * @param {React.ComponentType} opts.Press The user's default-exported Press component.
24
+ * @param {object} opts.PressContext The PressContext from @open-press/core.
25
+ * @param {Record<string, object>} opts.sources Resolved sources keyed by sourceId.
26
+ * @param {object|null} opts.hints Allocation hints (or null on first pass).
27
+ * @param {object|null} opts.allocation FrameAllocation map (or null for measurement).
28
+ * @returns {{ html: string, frames: Array<FrameInstance> }}
29
+ */
30
+ export function expandPressTree({ Press: UserPress, PressContext, sources, hints = null, allocation = null, toc = null }) {
31
+ const html = renderToStaticMarkup(
32
+ React.createElement(
33
+ PressContext.Provider,
34
+ { value: { sources, allocation, hints, toc } },
35
+ React.createElement(UserPress),
36
+ ),
37
+ );
38
+
39
+ const frames = extractFrames(html);
40
+ enforceUniqueFrameKeys(frames);
41
+ return { html, frames };
42
+ }
43
+
44
+ function extractFrames(html) {
45
+ const frames = [];
46
+ let match;
47
+ FRAME_OPEN_RE.lastIndex = 0;
48
+ while ((match = FRAME_OPEN_RE.exec(html)) !== null) {
49
+ const attrsBefore = match[1] ?? "";
50
+ const frameKey = match[2];
51
+ const attrsAfter = match[3] ?? "";
52
+ const allAttrs = `${attrsBefore} ${attrsAfter}`;
53
+ const role = pickAttr(allAttrs, "data-frame-role") || undefined;
54
+ const chromeRaw = pickAttr(allAttrs, "data-frame-chrome");
55
+ const chrome = chromeRaw === "false" ? false : true;
56
+ const openIndex = match.index;
57
+ const sectionHtml = sliceSection(html, openIndex);
58
+ const mdxAreas = extractMdxAreas(sectionHtml);
59
+ frames.push({
60
+ frameKey,
61
+ role,
62
+ chrome,
63
+ mdxAreas,
64
+ htmlStart: openIndex,
65
+ htmlEnd: openIndex + sectionHtml.length,
66
+ html: sectionHtml,
67
+ });
68
+ }
69
+ return frames;
70
+ }
71
+
72
+ const MDX_AREA_RE = /<([a-z][a-z0-9-]*)\b([^>]*)\bdata-openpress-mdx-area="true"([^>]*)>/gi;
73
+
74
+ function extractMdxAreas(sectionHtml) {
75
+ const areas = [];
76
+ let match;
77
+ MDX_AREA_RE.lastIndex = 0;
78
+ while ((match = MDX_AREA_RE.exec(sectionHtml)) !== null) {
79
+ const attrs = `${match[2] ?? ""} ${match[3] ?? ""}`;
80
+ const chainId = pickAttr(attrs, "data-openpress-mdx-area-chain");
81
+ const overflow = pickAttr(attrs, "data-openpress-mdx-area-overflow") || "extend";
82
+ if (!chainId) continue;
83
+ const indexInFrame = areas.filter((a) => a.chainId === chainId).length;
84
+ areas.push({ chainId, overflow, indexInFrame, indexAcrossFrame: areas.length });
85
+ }
86
+ return areas;
87
+ }
88
+
89
+ function pickAttr(attrs, name) {
90
+ const match = ATTR_RE(name).exec(attrs);
91
+ return match ? match[1] : "";
92
+ }
93
+
94
+ // Find the end of a <section> opening at `start`, returning the full
95
+ // `<section ...>...</section>` substring. Handles nested <section> elements
96
+ // by depth-counting.
97
+ function sliceSection(html, start) {
98
+ const sectionOpen = /<section\b[^>]*>/g;
99
+ const sectionClose = /<\/section\s*>/g;
100
+ sectionOpen.lastIndex = start + 1;
101
+ sectionClose.lastIndex = start + 1;
102
+ let depth = 1;
103
+ while (depth > 0) {
104
+ const nextOpen = sectionOpen.exec(html);
105
+ const nextClose = sectionClose.exec(html);
106
+ if (!nextClose) {
107
+ throw new Error(`Unterminated <section> in Press tree HTML near offset ${start}`);
108
+ }
109
+ if (nextOpen && nextOpen.index < nextClose.index) {
110
+ depth += 1;
111
+ sectionClose.lastIndex = nextOpen.index + 1;
112
+ continue;
113
+ }
114
+ depth -= 1;
115
+ if (depth === 0) {
116
+ return html.slice(start, nextClose.index + nextClose[0].length);
117
+ }
118
+ sectionOpen.lastIndex = nextClose.index + 1;
119
+ }
120
+ throw new Error(`Section depth balance bug at offset ${start}`);
121
+ }
122
+
123
+ function enforceUniqueFrameKeys(frames) {
124
+ const seen = new Map();
125
+ for (const frame of frames) {
126
+ if (seen.has(frame.frameKey)) {
127
+ const prior = seen.get(frame.frameKey);
128
+ throw new Error(
129
+ `Duplicate frameKey "${frame.frameKey}" found in Press tree. ` +
130
+ `First seen with role "${prior.role ?? "?"}", second with role "${frame.role ?? "?"}".`,
131
+ );
132
+ }
133
+ seen.set(frame.frameKey, frame);
134
+ }
135
+ }
@@ -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 { collectSourceTextFiles } from "../source-text-tools.mjs";
3
+ import { loadConfig } from "../runtime/config.mjs";
4
+ import { collectSourceTextFiles } from "../runtime/source-text-tools.mjs";
5
5
  import { insertCommentMarker } from "./comment-marker.mjs";
6
6
 
7
7
  const MAX_PROJECT_ASSET_BODY_BYTES = 64 * 1024;
@@ -3,13 +3,16 @@ import postcss from "postcss";
3
3
 
4
4
  const UNSCOPED_RULE_PARENTS = new Set(["keyframes", "-webkit-keyframes", "page"]);
5
5
 
6
- export async function buildChapterScopedCss(workspace) {
6
+ // Section-scoped CSS. Style files under `chapters/<slug>/styles/*.css` (in
7
+ // the section-folders preset) are scoped to `[data-section-id="<slug>"]`.
8
+ // Workspaces using other source presets do not get section-scoped CSS in v1.
9
+ export async function buildSectionScopedCss(workspace) {
7
10
  const parts = [];
8
- for (const chapter of workspace.chapters ?? []) {
9
- for (const styleFile of chapter.styleFiles ?? []) {
11
+ for (const section of workspace.sections ?? workspace.chapters ?? []) {
12
+ for (const styleFile of section.styleFiles ?? []) {
10
13
  const source = await fs.readFile(styleFile.absolutePath, "utf8");
11
- const scoped = await scopeChapterCss(source, {
12
- chapterSlug: chapter.slug,
14
+ const scoped = await scopeSectionCss(source, {
15
+ sectionSlug: section.slug,
13
16
  from: styleFile.absolutePath,
14
17
  });
15
18
  if (!scoped.trim()) continue;
@@ -21,10 +24,10 @@ export async function buildChapterScopedCss(workspace) {
21
24
  return parts.join("\n");
22
25
  }
23
26
 
24
- export async function scopeChapterCss(source, { chapterSlug, from = undefined } = {}) {
25
- if (typeof source !== "string") throw new Error("scopeChapterCss requires a CSS source string.");
26
- const slug = cssAttributeValue(chapterSlug);
27
- const scope = `[data-chapter-slug="${slug}"]`;
27
+ export async function scopeSectionCss(source, { sectionSlug, from = undefined } = {}) {
28
+ if (typeof source !== "string") throw new Error("scopeSectionCss requires a CSS source string.");
29
+ const slug = cssAttributeValue(sectionSlug);
30
+ const scope = `[data-section-id="${slug}"]`;
28
31
  const root = postcss.parse(source, { from });
29
32
 
30
33
  root.walkRules((rule) => {
@@ -0,0 +1,132 @@
1
+ // Heading numbering and outline emission for MDX sources.
2
+ //
3
+ // Pure functions called from `mdx-resolver.mjs` while walking each section's
4
+ // compiled blocks. Produces:
5
+ // - `data-chapter` / `data-section` / `data-topic` attributes on heading
6
+ // blocks (h2/h3/h4)
7
+ // - stable IDs on the same blocks
8
+ // - outline entries (for TOC + reader navigation)
9
+ //
10
+ // Numbering state is per-source, advances forward through blocks in source
11
+ // order. The caller threads `headingState` between calls.
12
+
13
+ /**
14
+ * Build a fresh per-source heading state. One state object per call to
15
+ * `resolveSource` — counts subsection / topic indices and tracks whether
16
+ * this section has already seen its chapter heading.
17
+ */
18
+ export function createHeadingState() {
19
+ return {
20
+ hasChapterHeading: false,
21
+ subsectionCounter: 0,
22
+ topicCounter: 0,
23
+ };
24
+ }
25
+
26
+ /**
27
+ * For one block emitted by the MDX compiler, decide whether it is a heading,
28
+ * what id/data-* attributes it should carry, and whether it adds an entry to
29
+ * the outline. Returns `null` for non-heading blocks.
30
+ *
31
+ * @param {object} opts
32
+ * @param {object} opts.block The compiled block record.
33
+ * @param {string} opts.sourceId Source registry key.
34
+ * @param {object} opts.section Section descriptor (slug, title, …).
35
+ * @param {Array} opts.outlineItems Mutable outline array — entries are pushed onto it.
36
+ * @param {number} opts.chapterNumber 1-based chapter index for this section.
37
+ * @param {string} opts.chapterLabel Display label, e.g. `"#3"` or `"03"`.
38
+ * @param {object} opts.headingState Per-source counter state from `createHeadingState`.
39
+ */
40
+ export function headingAttributesForBlock({
41
+ block,
42
+ sourceId,
43
+ section,
44
+ outlineItems,
45
+ chapterNumber,
46
+ chapterLabel,
47
+ headingState,
48
+ }) {
49
+ const title = String(block.text ?? "").trim();
50
+
51
+ if (block.name === "h2") {
52
+ const firstChapterHeading = !headingState.hasChapterHeading;
53
+ const id = firstChapterHeading ? `section-${section.slug}` : block.id;
54
+ const headingTitle = title || section.title || section.slug;
55
+ outlineItems.push({
56
+ id: `${sourceId}:${section.slug}:${block.id}`,
57
+ tocId: firstChapterHeading
58
+ ? `toc-${sourceId}-${section.slug}`
59
+ : `toc-${sourceId}-${section.slug}-${block.id}`,
60
+ depth: 0,
61
+ title: headingTitle,
62
+ label: chapterLabel,
63
+ sectionSlug: section.slug,
64
+ blockId: block.id,
65
+ href: `#${id}`,
66
+ });
67
+ headingState.hasChapterHeading = true;
68
+ headingState.subsectionCounter = 0;
69
+ headingState.topicCounter = 0;
70
+ return {
71
+ attributes: {
72
+ id,
73
+ "data-chapter": chapterLabel,
74
+ },
75
+ sectionTitle: firstChapterHeading ? headingTitle : undefined,
76
+ };
77
+ }
78
+
79
+ if (block.name === "h3") {
80
+ headingState.subsectionCounter += 1;
81
+ headingState.topicCounter = 0;
82
+ const label = `${chapterNumber}.${headingState.subsectionCounter}`;
83
+ outlineItems.push({
84
+ id: `${sourceId}:${section.slug}:${block.id}`,
85
+ tocId: `toc-${sourceId}-${section.slug}-${block.id}`,
86
+ depth: 1,
87
+ title: title || `Section ${label}`,
88
+ label,
89
+ sectionSlug: section.slug,
90
+ blockId: block.id,
91
+ href: `#${block.id}`,
92
+ });
93
+ return {
94
+ attributes: {
95
+ id: block.id,
96
+ "data-section": label,
97
+ },
98
+ };
99
+ }
100
+
101
+ if (block.name === "h4") {
102
+ headingState.topicCounter += 1;
103
+ const label = `${chapterNumber}.${Math.max(1, headingState.subsectionCounter)}.${headingState.topicCounter}`;
104
+ return {
105
+ attributes: {
106
+ id: block.id,
107
+ "data-topic": label,
108
+ },
109
+ };
110
+ }
111
+
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Build a fallback single-entry outline when a section has no h2/h3 headings
117
+ * the resolver could pick up. The outline still needs one row so the TOC
118
+ * doesn't lose the section.
119
+ */
120
+ export function fallbackOutlineItems({ sourceId, section, chapterLabel, title, blocks }) {
121
+ const targetBlock = blocks[0];
122
+ return [{
123
+ id: `${sourceId}:${section.slug}`,
124
+ tocId: `toc-${sourceId}-${section.slug}`,
125
+ depth: 0,
126
+ title,
127
+ label: chapterLabel,
128
+ sectionSlug: section.slug,
129
+ blockId: targetBlock?.id,
130
+ href: `#section-${section.slug}`,
131
+ }];
132
+ }