@open-press/core 0.3.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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/engine/chrome-pdf.d.mts +34 -0
  4. package/engine/chrome-pdf.mjs +344 -0
  5. package/engine/cli.mjs +93 -0
  6. package/engine/commands/_shared.mjs +170 -0
  7. package/engine/commands/deploy.mjs +31 -0
  8. package/engine/commands/dev.mjs +26 -0
  9. package/engine/commands/export.mjs +8 -0
  10. package/engine/commands/init.mjs +24 -0
  11. package/engine/commands/inspect.mjs +35 -0
  12. package/engine/commands/migrate-to-react.mjs +27 -0
  13. package/engine/commands/pdf.mjs +26 -0
  14. package/engine/commands/preview.mjs +26 -0
  15. package/engine/commands/render.mjs +17 -0
  16. package/engine/commands/replace.mjs +41 -0
  17. package/engine/commands/search.mjs +33 -0
  18. package/engine/commands/typecheck.mjs +5 -0
  19. package/engine/commands/validate.mjs +17 -0
  20. package/engine/config.d.mts +40 -0
  21. package/engine/config.mjs +160 -0
  22. package/engine/deploy-sync.mjs +15 -0
  23. package/engine/document-export.mjs +15 -0
  24. package/engine/file-utils.mjs +106 -0
  25. package/engine/fonts.mjs +62 -0
  26. package/engine/init.mjs +90 -0
  27. package/engine/inspection.mjs +348 -0
  28. package/engine/issue-report.mjs +44 -0
  29. package/engine/katex-assets.mjs +45 -0
  30. package/engine/page-block.mjs +30 -0
  31. package/engine/page-renderer.mjs +217 -0
  32. package/engine/pdf-media.mjs +45 -0
  33. package/engine/public-assets.mjs +19 -0
  34. package/engine/react/chapter-css.mjs +53 -0
  35. package/engine/react/comment-endpoint.d.mts +11 -0
  36. package/engine/react/comment-endpoint.mjs +128 -0
  37. package/engine/react/comment-marker.mjs +306 -0
  38. package/engine/react/document-entry.mjs +253 -0
  39. package/engine/react/document-export.mjs +392 -0
  40. package/engine/react/mdx-compile.mjs +295 -0
  41. package/engine/react/measurement-css.mjs +44 -0
  42. package/engine/react/migrate-to-react.mjs +355 -0
  43. package/engine/react/pagination-constants.mjs +3 -0
  44. package/engine/react/pagination.mjs +121 -0
  45. package/engine/react/project-asset-endpoint.d.mts +10 -0
  46. package/engine/react/project-asset-endpoint.mjs +379 -0
  47. package/engine/react/workspace-discovery.mjs +156 -0
  48. package/engine/source-text-tools.mjs +280 -0
  49. package/engine/source-workspace.mjs +76 -0
  50. package/engine/static-server.mjs +493 -0
  51. package/engine/validation.mjs +172 -0
  52. package/index.html +13 -0
  53. package/package.json +86 -0
  54. package/src/openpress/App.tsx +127 -0
  55. package/src/openpress/composerMentions.ts +188 -0
  56. package/src/openpress/core/basePages.tsx +87 -0
  57. package/src/openpress/core/index.tsx +20 -0
  58. package/src/openpress/core/types.ts +71 -0
  59. package/src/openpress/frameScheduler.ts +32 -0
  60. package/src/openpress/indexes.ts +329 -0
  61. package/src/openpress/inspector.ts +282 -0
  62. package/src/openpress/pageRoute.ts +21 -0
  63. package/src/openpress/pagination.ts +845 -0
  64. package/src/openpress/projectIdentity.ts +15 -0
  65. package/src/openpress/projectSources.ts +24 -0
  66. package/src/openpress/projectWorkspace.tsx +919 -0
  67. package/src/openpress/publicPage.tsx +469 -0
  68. package/src/openpress/reactDocumentMetadata.ts +41 -0
  69. package/src/openpress/readerPageRegistry.ts +41 -0
  70. package/src/openpress/readerRuntime.ts +230 -0
  71. package/src/openpress/readerScroll.ts +92 -0
  72. package/src/openpress/readerState.ts +15 -0
  73. package/src/openpress/renderer.tsx +91 -0
  74. package/src/openpress/runtimeMode.ts +22 -0
  75. package/src/openpress/types.ts +112 -0
  76. package/src/openpress/workbench.tsx +1299 -0
  77. package/src/openpress/workbenchPanels.tsx +122 -0
  78. package/src/openpress/workbenchTypes.ts +4 -0
  79. package/src/styles/openpress/app-shell.css +251 -0
  80. package/src/styles/openpress/media-workspace.css +230 -0
  81. package/src/styles/openpress/print-route.css +186 -0
  82. package/src/styles/openpress/project-workspace.css +1318 -0
  83. package/src/styles/openpress/public-viewer.css +983 -0
  84. package/src/styles/openpress/reader-runtime.css +792 -0
  85. package/src/styles/openpress/responsive.css +384 -0
  86. package/src/styles/openpress/workbench-panels.css +558 -0
  87. package/src/styles/openpress/workbench.css +720 -0
  88. package/src/styles/openpress.css +14 -0
  89. package/tsconfig.json +37 -0
  90. package/vite.config.ts +512 -0
@@ -0,0 +1,392 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import React from "react";
4
+ import { renderToStaticMarkup } from "react-dom/server";
5
+ import { documentRelativePath, pageToBlock } from "../page-block.mjs";
6
+ import { injectStaticToc } from "../page-renderer.mjs";
7
+ import { syncPublicAssets } from "../public-assets.mjs";
8
+ import { buildChapterScopedCss } from "./chapter-css.mjs";
9
+ import { loadReactDocumentEntry, createReactSsrServer } from "./document-entry.mjs";
10
+ import { buildReactMeasurementCss } from "./measurement-css.mjs";
11
+ import { compileMdx } from "./mdx-compile.mjs";
12
+ import { measureBlocksInChromium } from "./pagination.mjs";
13
+ import { discoverReactWorkspace } from "./workspace-discovery.mjs";
14
+
15
+ export async function exportReactDocument(root = ".", { syncAssets = true, pagination = null } = {}) {
16
+ const workspaceRoot = path.resolve(root);
17
+ const entry = await loadReactDocumentEntry(workspaceRoot);
18
+ if (!entry) return null;
19
+
20
+ const workspace = await discoverReactWorkspace(workspaceRoot, entry.config);
21
+ const paginationOptions = normalizePaginationOptions(pagination);
22
+ if (paginationOptions.enabled && paginationOptions.needsMeasurementCss) {
23
+ paginationOptions.css = await buildReactMeasurementCss(workspaceRoot, entry.config, workspace);
24
+ }
25
+ const server = await createReactSsrServer(workspaceRoot);
26
+ try {
27
+ const pageJobs = [];
28
+ const blockMap = {};
29
+ const paginationWarnings = [];
30
+ addShellPage(pageJobs, entry.shell.cover, shellSource(entry.config, "cover"));
31
+ addShellPage(pageJobs, entry.shell.toc, shellSource(entry.config, "toc"));
32
+
33
+ for (const [chapterIndex, chapter] of workspace.chapters.entries()) {
34
+ const chapterModule = await loadChapterModule(server, chapter);
35
+ const chapterMeta = normalizeChapterMeta(chapter, chapterModule.meta);
36
+ const components = await loadComponentScope(server, chapter.componentScope);
37
+ const Page = typeof chapterModule.Page === "function" ? chapterModule.Page : components.Page ?? DefaultContentPage;
38
+
39
+ addShellPage(
40
+ pageJobs,
41
+ chapterModule.opener ?? null,
42
+ chapterSource(entry.config, chapter, {
43
+ chapterIndex,
44
+ kind: "chapter-opener",
45
+ slug: chapterMeta.slug,
46
+ title: chapterMeta.title,
47
+ }),
48
+ );
49
+
50
+ for (const contentFile of chapter.contentFiles) {
51
+ const source = await fs.readFile(contentFile.absolutePath, "utf8");
52
+ const compiled = await compileMdx({
53
+ source,
54
+ filePath: contentFile.absolutePath,
55
+ components,
56
+ chapterSlug: chapterMeta.slug,
57
+ });
58
+ const sourceRecord = chapterSource(entry.config, chapter, {
59
+ chapterIndex,
60
+ contentFile,
61
+ kind: "content",
62
+ slug: chapterMeta.slug,
63
+ title: chapterMeta.title,
64
+ });
65
+ const mdxBlocks = compiled.blocks.map((block) => sanitizeMdxBlock(entry.config, contentFile, block));
66
+
67
+ if (!paginationOptions.enabled || mdxBlocks.length === 0) {
68
+ pageJobs.push(mdxPageJob({
69
+ Page,
70
+ Content: compiled.Content,
71
+ source: sourceRecord,
72
+ mdxBlocks,
73
+ chapterMeta,
74
+ }));
75
+ continue;
76
+ }
77
+
78
+ const measurementHtml = renderToStaticMarkup(React.createElement(
79
+ Page,
80
+ {
81
+ pageIndex: 0,
82
+ totalPages: 1,
83
+ chapterSlug: chapterMeta.slug,
84
+ chapterTone: chapterMeta.tone,
85
+ },
86
+ React.createElement(compiled.Content),
87
+ ));
88
+ const measured = await paginationOptions.measureBlocks({
89
+ html: measurementHtml,
90
+ blockIds: mdxBlocks.map((block) => block.id),
91
+ pageSafeHeightPx: paginationOptions.pageSafeHeightPx,
92
+ css: paginationOptions.css,
93
+ chapterSlug: chapterMeta.slug,
94
+ contentFile,
95
+ source: sourceRecord,
96
+ });
97
+ const blockLookup = Object.fromEntries(mdxBlocks.map((block) => [block.id, block]));
98
+ for (const warning of measured.warnings ?? []) {
99
+ paginationWarnings.push(enrichPaginationWarning(warning, blockLookup));
100
+ }
101
+
102
+ for (const measuredPage of measured.pages ?? []) {
103
+ const pageCompiled = await compileMdx({
104
+ source,
105
+ filePath: contentFile.absolutePath,
106
+ components,
107
+ chapterSlug: chapterMeta.slug,
108
+ includeBlockIds: measuredPage.blockIds,
109
+ });
110
+ const pageBlockSet = new Set(measuredPage.blockIds);
111
+ pageJobs.push(mdxPageJob({
112
+ Page,
113
+ Content: pageCompiled.Content,
114
+ source: {
115
+ ...sourceRecord,
116
+ sectionIndex: measuredPage.pageIndex + 1,
117
+ },
118
+ mdxBlocks: mdxBlocks.filter((block) => pageBlockSet.has(block.id)),
119
+ chapterMeta,
120
+ pagination: {
121
+ blockIds: measuredPage.blockIds,
122
+ breakAfter: measuredPage.breakAfter,
123
+ },
124
+ }));
125
+ }
126
+ }
127
+ }
128
+
129
+ addShellPage(pageJobs, entry.shell.backCover, shellSource(entry.config, "back-cover"));
130
+
131
+ const renderedPages = renderPageJobsWithInjectedToc(pageJobs);
132
+ const blocks = renderedPages.map((page, index) => {
133
+ for (const block of page.mdxBlocks ?? []) {
134
+ blockMap[block.id] = {
135
+ ...block,
136
+ pageIndex: index,
137
+ pageNumber: index + 1,
138
+ };
139
+ }
140
+ return pageToBlock(index, page.html, page.source, entry.config);
141
+ });
142
+ const chapterCss = await buildChapterScopedCss(workspace);
143
+ const styles = [];
144
+ if (chapterCss.trim()) {
145
+ await fs.mkdir(entry.config.paths.publicDir, { recursive: true });
146
+ await fs.writeFile(path.join(entry.config.paths.publicDir, "chapter-scoped.css"), chapterCss, "utf8");
147
+ styles.push({
148
+ kind: "chapter-scoped-css",
149
+ href: "/openpress/chapter-scoped.css",
150
+ path: "chapter-scoped.css",
151
+ });
152
+ }
153
+
154
+ const readerDocument = {
155
+ meta: {
156
+ title: trimmedString(entry.config.title) ?? "Untitled Document",
157
+ subtitle: trimmedString(entry.config.subtitle) ?? "",
158
+ organization: trimmedString(entry.config.organization) ?? "",
159
+ workspaceLabel: trimmedString(entry.config.workspaceLabel) ?? trimmedString(entry.config.title) ?? "Untitled Document",
160
+ version: "openpress-react-export-v1",
161
+ },
162
+ source: {
163
+ type: "openpress-react-mdx",
164
+ contentDir: documentRelativePath(entry.config, entry.config.sourceDir),
165
+ editable: true,
166
+ editMode: "source-mdx",
167
+ styles,
168
+ blockMap,
169
+ ...(paginationOptions.enabled ? {
170
+ pagination: {
171
+ mode: "build-time-block-measurement",
172
+ ...(paginationOptions.pageSafeHeightPx ? { pageSafeHeightPx: paginationOptions.pageSafeHeightPx } : {}),
173
+ warnings: paginationWarnings,
174
+ },
175
+ } : {}),
176
+ },
177
+ blocks,
178
+ };
179
+
180
+ const documentPath = path.join(entry.config.paths.publicDir, "document.json");
181
+ await fs.mkdir(entry.config.paths.publicDir, { recursive: true });
182
+ await fs.writeFile(documentPath, JSON.stringify(readerDocument, null, 2), "utf8");
183
+ if (syncAssets) {
184
+ await syncPublicAssets(workspaceRoot, entry.config.paths.publicDir, entry.config);
185
+ }
186
+ return { documentPath, pageCount: blocks.length, document: readerDocument };
187
+ } finally {
188
+ await server.close();
189
+ }
190
+ }
191
+
192
+ function renderPageJobsWithInjectedToc(pageJobs) {
193
+ let records = renderPageJobs(pageJobs, pageJobs.length);
194
+ let injectedHtml = injectStaticToc(records.map((record) => record.html));
195
+ if (injectedHtml.length !== records.length) {
196
+ records = renderPageJobs(pageJobs, injectedHtml.length);
197
+ injectedHtml = injectStaticToc(records.map((record) => record.html));
198
+ }
199
+ return alignInjectedTocRecords(records, injectedHtml);
200
+ }
201
+
202
+ function renderPageJobs(pageJobs, totalPages) {
203
+ return pageJobs.map((job, index) => ({
204
+ html: renderToStaticMarkup(job.render(index, totalPages)),
205
+ source: job.source,
206
+ mdxBlocks: job.mdxBlocks ?? [],
207
+ }));
208
+ }
209
+
210
+ function alignInjectedTocRecords(records, injectedHtml) {
211
+ if (injectedHtml.length === records.length) {
212
+ return records.map((record, index) => ({
213
+ ...record,
214
+ html: injectedHtml[index],
215
+ }));
216
+ }
217
+
218
+ const tocIndex = records.findIndex((record) => hasReaderPageKind(record.html, "toc"));
219
+ const extra = injectedHtml.length - records.length;
220
+ if (tocIndex < 0 || extra < 1) {
221
+ throw new Error(`React TOC injection changed page count unexpectedly: ${records.length} -> ${injectedHtml.length}`);
222
+ }
223
+
224
+ return injectedHtml.map((html, index) => {
225
+ if (index < tocIndex) return { ...records[index], html };
226
+ if (index <= tocIndex + extra) {
227
+ return {
228
+ html,
229
+ source: {
230
+ ...records[tocIndex].source,
231
+ sectionIndex: index - tocIndex + 1,
232
+ },
233
+ mdxBlocks: [],
234
+ };
235
+ }
236
+ const sourceRecord = records[index - extra];
237
+ return {
238
+ ...sourceRecord,
239
+ html,
240
+ };
241
+ });
242
+ }
243
+
244
+ function hasReaderPageKind(html, kind) {
245
+ const openingTag = String(html).match(/^<section[^>]*>/i)?.[0] ?? "";
246
+ return openingTag.match(/\bdata-page-kind="([^"]*)"/i)?.[1] === kind;
247
+ }
248
+
249
+ function addShellPage(pageJobs, element, source) {
250
+ if (element == null) return;
251
+ pageJobs.push({
252
+ source,
253
+ render() {
254
+ return element;
255
+ },
256
+ });
257
+ }
258
+
259
+ function mdxPageJob({ Page, Content, source, mdxBlocks, chapterMeta, pagination = null }) {
260
+ return {
261
+ source,
262
+ mdxBlocks,
263
+ pagination,
264
+ render(pageIndex, totalPages) {
265
+ return React.createElement(
266
+ Page,
267
+ {
268
+ pageIndex,
269
+ totalPages,
270
+ chapterSlug: chapterMeta.slug,
271
+ chapterTone: chapterMeta.tone,
272
+ },
273
+ React.createElement(Content),
274
+ );
275
+ },
276
+ };
277
+ }
278
+
279
+ async function loadChapterModule(server, chapter) {
280
+ if (!chapter.chapterEntry) return {};
281
+ return server.ssrLoadModule(chapter.chapterEntry.absolutePath);
282
+ }
283
+
284
+ async function loadComponentScope(server, componentScope) {
285
+ const components = {};
286
+ for (const [name, component] of Object.entries(componentScope ?? {})) {
287
+ const mod = await server.ssrLoadModule(component.absolutePath);
288
+ if (typeof mod.default !== "function") {
289
+ throw new Error(`OpenPress React component must default-export a component: ${component.documentPath}`);
290
+ }
291
+ components[name] = mod.default;
292
+ }
293
+ return components;
294
+ }
295
+
296
+ function normalizeChapterMeta(chapter, meta) {
297
+ const rawMeta = meta && typeof meta === "object" ? meta : {};
298
+ return {
299
+ slug: trimmedString(rawMeta.slug) ?? chapter.slug,
300
+ title: trimmedString(rawMeta.title) ?? chapter.slug,
301
+ tone: trimmedString(rawMeta.tone) ?? undefined,
302
+ };
303
+ }
304
+
305
+ function shellSource(config, kind) {
306
+ return {
307
+ file: "index.tsx",
308
+ path: documentRelativePath(config, "index.tsx"),
309
+ kind,
310
+ slug: kind,
311
+ sectionIndex: 1,
312
+ };
313
+ }
314
+
315
+ function chapterSource(config, chapter, { chapterIndex, contentFile, kind, slug, title }) {
316
+ const file = contentFile?.documentPath ?? chapter.chapterEntry?.documentPath ?? chapter.documentPath;
317
+ return {
318
+ file: path.basename(file),
319
+ path: documentRelativePath(config, file),
320
+ kind,
321
+ chapter: chapterIndex + 1,
322
+ slug,
323
+ title,
324
+ sectionIndex: 1,
325
+ };
326
+ }
327
+
328
+ function sanitizeMdxBlock(config, contentFile, block) {
329
+ return {
330
+ id: block.id,
331
+ kind: block.kind,
332
+ name: block.name,
333
+ chapterSlug: block.chapterSlug,
334
+ path: documentRelativePath(config, contentFile.documentPath),
335
+ source: block.source,
336
+ };
337
+ }
338
+
339
+ function enrichPaginationWarning(warning, blockLookup) {
340
+ const block = blockLookup[warning.blockId];
341
+ return {
342
+ ...warning,
343
+ ...(block ? {
344
+ path: block.path,
345
+ source: block.source,
346
+ } : {}),
347
+ };
348
+ }
349
+
350
+ function normalizePaginationOptions(pagination) {
351
+ if (!pagination?.enabled) {
352
+ return { enabled: false };
353
+ }
354
+ const pageSafeHeightPx = positiveNumber(pagination.pageSafeHeightPx, null);
355
+ return {
356
+ enabled: true,
357
+ pageSafeHeightPx,
358
+ needsMeasurementCss: typeof pagination.measureBlocks !== "function",
359
+ measureBlocks: pagination.measureBlocks ?? ((input) => measureBlocksInChromium({
360
+ html: input.html,
361
+ css: input.css,
362
+ pageSafeHeightPx,
363
+ })),
364
+ };
365
+ }
366
+
367
+ function positiveNumber(value, fallback) {
368
+ const number = Number(value);
369
+ return Number.isFinite(number) && number > 0 ? number : fallback;
370
+ }
371
+
372
+ function DefaultContentPage({ pageIndex, totalPages, chapterSlug, chapterTone, children }) {
373
+ return React.createElement(
374
+ "section",
375
+ {
376
+ className: "reader-page reader-page--content",
377
+ "data-page-footer": "true",
378
+ "data-page-kind": "content",
379
+ "data-page-index": pageIndex,
380
+ "data-total-pages": totalPages,
381
+ "data-chapter-slug": chapterSlug,
382
+ "data-chapter-tone": chapterTone,
383
+ },
384
+ children,
385
+ );
386
+ }
387
+
388
+ function trimmedString(value) {
389
+ if (typeof value !== "string") return null;
390
+ const trimmed = value.trim();
391
+ return trimmed ? trimmed : null;
392
+ }
@@ -0,0 +1,295 @@
1
+ import path from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { evaluate } from "@mdx-js/mdx";
4
+ import React from "react";
5
+ import * as jsxRuntime from "react/jsx-runtime";
6
+ import rehypeKatex from "rehype-katex";
7
+ import remarkGfm from "remark-gfm";
8
+ import remarkMath from "remark-math";
9
+
10
+ const PAGINABLE_TAGS = new Set([
11
+ "p",
12
+ "h1",
13
+ "h2",
14
+ "h3",
15
+ "h4",
16
+ "h5",
17
+ "h6",
18
+ "ul",
19
+ "ol",
20
+ "pre",
21
+ "blockquote",
22
+ "figure",
23
+ "table",
24
+ ]);
25
+ const TABLE_CAPTION_COMPONENT_NAME = "TableCaption";
26
+ const LEGACY_TABLE_CAPTION_MARKER_RE = /^\s*表\s*(?:[\d一二三四五六七八九十百千〇零]+(?:[--..][\d一二三四五六七八九十百千〇零]+)?)?\s*[::、..]\s*(.+?)\s*$/u;
27
+
28
+ export async function compileMdx({
29
+ source,
30
+ filePath,
31
+ components = {},
32
+ chapterSlug = "document",
33
+ includeBlockIds = null,
34
+ } = {}) {
35
+ if (typeof source !== "string") throw new Error("compileMdx requires a string `source`.");
36
+ if (typeof filePath !== "string" || !filePath.trim()) throw new Error("compileMdx requires `filePath`.");
37
+ assertNoImports(source, filePath);
38
+ const mdxSource = normalizeSingleLineDisplayMath(source);
39
+
40
+ const blocks = [];
41
+ const remarkPlugins = [[remarkMath, { singleDollarTextMath: true }], remarkGfm, [remarkBlockOnlyMdx, { filePath }]];
42
+ const rehypePlugins = [rehypeKatex, rehypeTableCaptions, [rehypeBlockIds, { blocks, filePath, chapterSlug, includeBlockIds }]];
43
+ const mod = await evaluate(mdxSource, {
44
+ ...jsxRuntime,
45
+ baseUrl: pathToFileURL(filePath).href,
46
+ remarkPlugins,
47
+ rehypePlugins,
48
+ });
49
+ const MdxContent = mod.default;
50
+ const mdxComponents = wrapMdxComponents(components);
51
+
52
+ function MdxContentWrapper(props = {}) {
53
+ return React.createElement(MdxContent, {
54
+ ...props,
55
+ components: {
56
+ ...mdxComponents,
57
+ ...(props.components ?? {}),
58
+ },
59
+ });
60
+ }
61
+
62
+ return {
63
+ Content: MdxContentWrapper,
64
+ blocks,
65
+ exports: mod,
66
+ };
67
+ }
68
+
69
+ export function rehypeTableCaptions() {
70
+ return (tree) => {
71
+ normalizeTableCaptions(tree);
72
+ };
73
+ }
74
+
75
+ export function rehypeBlockIds(options = {}) {
76
+ const blocks = Array.isArray(options.blocks) ? options.blocks : [];
77
+ const filePath = String(options.filePath ?? "document.mdx");
78
+ const chapterSlug = slugPart(options.chapterSlug ?? "document");
79
+ const sourceSlug = slugPart(path.basename(filePath, path.extname(filePath)));
80
+ const includeBlockIds = Array.isArray(options.includeBlockIds) ? new Set(options.includeBlockIds) : null;
81
+ let counter = 0;
82
+
83
+ return (tree) => {
84
+ filterTree(tree, (node) => {
85
+ const block = blockInfo(node);
86
+ if (!block) return true;
87
+
88
+ const id = `b-${chapterSlug}-${sourceSlug}-${counter}`;
89
+ counter += 1;
90
+ if (includeBlockIds && !includeBlockIds.has(id)) return false;
91
+
92
+ setDataAttribute(node, "data-openpress-block-id", id);
93
+ blocks.push({
94
+ id,
95
+ kind: block.kind,
96
+ name: block.name,
97
+ filePath,
98
+ chapterSlug,
99
+ source: sourcePosition(node.position),
100
+ });
101
+ return true;
102
+ });
103
+ };
104
+ }
105
+
106
+ export function remarkBlockOnlyMdx(options = {}) {
107
+ const filePath = String(options.filePath ?? "document.mdx");
108
+
109
+ return (tree) => {
110
+ visit(tree, (node) => {
111
+ if (node?.type !== "mdxJsxTextElement") return;
112
+ const position = node.position?.start;
113
+ const suffix = position ? `:${position.line}:${position.column}` : "";
114
+ throw new Error(`MDX JSX components must be block-only in OpenPress chapter prose: ${filePath}${suffix}`);
115
+ });
116
+ };
117
+ }
118
+
119
+ function normalizeTableCaptions(node) {
120
+ if (!Array.isArray(node?.children)) return;
121
+
122
+ for (let index = 0; index < node.children.length; index += 1) {
123
+ const child = node.children[index];
124
+ normalizeTableCaptions(child);
125
+
126
+ const legacyCaptionText = legacyTableCaptionText(child);
127
+ if (legacyCaptionText) {
128
+ throw new Error(`Legacy table caption markers are not supported. Use <TableCaption>${legacyCaptionText}</TableCaption> before the table.`);
129
+ }
130
+
131
+ const captionText = tableCaptionText(child);
132
+ if (!captionText) continue;
133
+
134
+ const tableIndex = nextElementIndex(node.children, index + 1);
135
+ const table = tableIndex === -1 ? null : node.children[tableIndex];
136
+ if (!table || table.type !== "element" || table.tagName !== "table") {
137
+ throw new Error(`<${TABLE_CAPTION_COMPONENT_NAME}> must appear immediately before a Markdown table.`);
138
+ }
139
+
140
+ if (!table.children?.some((item) => item.type === "element" && item.tagName === "caption")) {
141
+ table.children ??= [];
142
+ table.children.unshift({
143
+ type: "element",
144
+ tagName: "caption",
145
+ properties: {},
146
+ children: [{ type: "text", value: captionText }],
147
+ });
148
+ }
149
+
150
+ node.children.splice(index, tableIndex - index);
151
+ index -= 1;
152
+ }
153
+ }
154
+
155
+ function legacyTableCaptionText(node) {
156
+ if (node?.type !== "element" || node.tagName !== "p") return "";
157
+ const match = textContent(node).match(LEGACY_TABLE_CAPTION_MARKER_RE);
158
+ return match?.[1]?.trim() ?? "";
159
+ }
160
+
161
+ function tableCaptionText(node) {
162
+ if (node?.type !== "mdxJsxFlowElement" || node.name !== TABLE_CAPTION_COMPONENT_NAME) return "";
163
+ const caption = textContent(node).trim();
164
+ if (!caption) throw new Error(`<${TABLE_CAPTION_COMPONENT_NAME}> requires caption text.`);
165
+ return caption;
166
+ }
167
+
168
+ function nextElementIndex(children, start) {
169
+ for (let index = start; index < children.length; index += 1) {
170
+ const child = children[index];
171
+ if (child?.type === "text" && !String(child.value ?? "").trim()) continue;
172
+ return child?.type === "element" ? index : -1;
173
+ }
174
+ return -1;
175
+ }
176
+
177
+ function textContent(node) {
178
+ if (node?.type === "text") return String(node.value ?? "");
179
+ if (!Array.isArray(node?.children)) return "";
180
+ return node.children.map(textContent).join("");
181
+ }
182
+
183
+ function wrapMdxComponents(components) {
184
+ const wrapped = {};
185
+ for (const [name, Component] of Object.entries(components ?? {})) {
186
+ if (typeof Component !== "function") continue;
187
+ wrapped[name] = function ComponentBlock(props = {}) {
188
+ const blockId = props["data-openpress-block-id"];
189
+ const rest = { ...props };
190
+ delete rest["data-openpress-block-id"];
191
+
192
+ if (!blockId) return React.createElement(Component, rest);
193
+
194
+ return React.createElement(
195
+ "div",
196
+ {
197
+ "data-openpress-block-id": blockId,
198
+ "data-openpress-component-block": name,
199
+ },
200
+ React.createElement(Component, rest),
201
+ );
202
+ };
203
+ }
204
+ return wrapped;
205
+ }
206
+
207
+ function assertNoImports(source, filePath) {
208
+ if (/^\s*import\s/m.test(source)) {
209
+ throw new Error(`MDX imports are not supported in OpenPress chapter prose: ${filePath}`);
210
+ }
211
+ }
212
+
213
+ function normalizeSingleLineDisplayMath(source) {
214
+ const fences = [];
215
+ const withoutFences = source.replace(/(```[\s\S]*?```|~~~[\s\S]*?~~~)/g, (match) => {
216
+ const token = `@@MDX_FENCE_${fences.length}@@`;
217
+ fences.push(match);
218
+ return token;
219
+ });
220
+
221
+ const normalized = withoutFences.replace(/^([ \t]*)\$\$([^\n]+?)\$\$[ \t]*$/gm, (_match, indent, math) => (
222
+ `${indent}$$\n${indent}${math.trim()}\n${indent}$$`
223
+ ));
224
+
225
+ return normalized.replace(/@@MDX_FENCE_(\d+)@@/g, (_match, index) => fences[Number(index)] ?? "");
226
+ }
227
+
228
+ function blockInfo(node) {
229
+ if (node?.type === "element" && PAGINABLE_TAGS.has(node.tagName)) {
230
+ return { kind: "element", name: node.tagName };
231
+ }
232
+ if (node?.type === "element" && node.tagName === "span" && hasClassName(node, "katex-display")) {
233
+ return { kind: "element", name: "math" };
234
+ }
235
+ if (node?.type === "mdxJsxFlowElement" && typeof node.name === "string" && node.name) {
236
+ return { kind: "component", name: node.name };
237
+ }
238
+ return null;
239
+ }
240
+
241
+ function hasClassName(node, className) {
242
+ const raw = node?.properties?.className;
243
+ if (Array.isArray(raw)) return raw.includes(className);
244
+ if (typeof raw === "string") return raw.split(/\s+/).includes(className);
245
+ return false;
246
+ }
247
+
248
+ function setDataAttribute(node, name, value) {
249
+ if (node.type === "mdxJsxFlowElement") {
250
+ node.attributes ??= [];
251
+ node.attributes.push({
252
+ type: "mdxJsxAttribute",
253
+ name,
254
+ value,
255
+ });
256
+ return;
257
+ }
258
+
259
+ node.properties ??= {};
260
+ node.properties[name] = value;
261
+ }
262
+
263
+ function visit(node, visitor) {
264
+ visitor(node);
265
+ if (!Array.isArray(node?.children)) return;
266
+ for (const child of node.children) visit(child, visitor);
267
+ }
268
+
269
+ function filterTree(node, visitor) {
270
+ const keep = visitor(node);
271
+ if (!keep) return false;
272
+ if (!Array.isArray(node?.children)) return true;
273
+ node.children = node.children.filter((child) => filterTree(child, visitor));
274
+ return true;
275
+ }
276
+
277
+ function sourcePosition(position) {
278
+ if (!position?.start || !position?.end) return undefined;
279
+ return {
280
+ line: position.start.line,
281
+ column: position.start.column,
282
+ endLine: position.end.line,
283
+ endColumn: position.end.column,
284
+ };
285
+ }
286
+
287
+ function slugPart(value) {
288
+ const slug = String(value)
289
+ .trim()
290
+ .replace(/\.[^.]+$/, "")
291
+ .replace(/[^A-Za-z0-9_-]+/g, "-")
292
+ .replace(/^-+|-+$/g, "")
293
+ .toLowerCase();
294
+ return slug || "document";
295
+ }