@open-press/core 1.2.0 → 1.3.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 (76) hide show
  1. package/README.md +2 -2
  2. package/engine/cli.mjs +1 -1
  3. package/engine/commands/_shared.mjs +10 -5
  4. package/engine/commands/deploy.mjs +19 -4
  5. package/engine/commands/typecheck.mjs +1 -1
  6. package/engine/document-export.mjs +1 -1
  7. package/engine/output/page-block.mjs +11 -2
  8. package/engine/output/public-assets.mjs +41 -6
  9. package/engine/output/static-server.mjs +84 -24
  10. package/engine/react/caption-numbering.mjs +2 -2
  11. package/engine/react/comment-marker.mjs +1 -2
  12. package/engine/react/document-entry.mjs +64 -11
  13. package/engine/react/document-export.d.mts +6 -0
  14. package/engine/react/document-export.mjs +158 -28
  15. package/engine/react/mdx-compile.mjs +4 -4
  16. package/engine/react/measurement-css.mjs +3 -3
  17. package/engine/react/page-folio.mjs +37 -0
  18. package/engine/react/pagination/allocator.mjs +4 -4
  19. package/engine/react/pipeline/frame-measurement.mjs +34 -16
  20. package/engine/react/press-tree-inspection.mjs +43 -13
  21. package/engine/react/project-asset-endpoint.mjs +45 -11
  22. package/engine/react/sources/heading-numbering.mjs +2 -2
  23. package/engine/react/sources/mdx-resolver.mjs +3 -3
  24. package/engine/react/style-discovery.mjs +60 -11
  25. package/engine/react/text-source-transform.mjs +18 -4
  26. package/engine/runtime/config.mjs +22 -22
  27. package/engine/runtime/file-utils.mjs +57 -13
  28. package/engine/runtime/inspection.mjs +40 -15
  29. package/engine/runtime/page-geometry.mjs +6 -6
  30. package/engine/runtime/source-text-tools.mjs +28 -4
  31. package/engine/runtime/source-workspace.mjs +6 -9
  32. package/engine/runtime/validation.mjs +42 -24
  33. package/package.json +1 -1
  34. package/src/openpress/app/OpenPressApp.tsx +10 -16
  35. package/src/openpress/app/OpenPressRuntime.tsx +29 -4
  36. package/src/openpress/app/WorkspaceGalleryPage.tsx +1 -1
  37. package/src/openpress/core/PageFolio.tsx +115 -0
  38. package/src/openpress/core/Press.tsx +5 -10
  39. package/src/openpress/core/Slide.tsx +11 -0
  40. package/src/openpress/core/index.tsx +4 -0
  41. package/src/openpress/core/types.ts +21 -13
  42. package/src/openpress/core/useSource.ts +1 -1
  43. package/src/openpress/document-model/workspaceManifestModel.ts +4 -9
  44. package/src/openpress/reader/PageThumbnailsPanel.tsx +28 -5
  45. package/src/openpress/reader/SlidePresentationPage.tsx +36 -19
  46. package/src/openpress/reader/SlidePublicPage.tsx +332 -0
  47. package/src/openpress/reader/index.ts +1 -0
  48. package/src/openpress/reader/pageViewportScaleModel.ts +5 -3
  49. package/src/openpress/reader/usePageViewportScale.ts +9 -5
  50. package/src/openpress/workbench/Workbench.tsx +46 -164
  51. package/src/openpress/workbench/actions/DeploymentControl.tsx +1 -1
  52. package/src/openpress/workbench/actions/ExportControl.tsx +267 -0
  53. package/src/openpress/workbench/actions/index.ts +1 -1
  54. package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +7 -2
  55. package/src/openpress/workbench/hooks/useWorkbenchNavigation.ts +42 -0
  56. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +2 -278
  57. package/src/openpress/workbench/shell/WorkbenchToolbarActions.tsx +206 -0
  58. package/src/styles/openpress/app-shell.css +0 -83
  59. package/src/styles/openpress/print-route.css +1 -3
  60. package/src/styles/openpress/project-preview-panel.css +5 -783
  61. package/src/styles/openpress/public-viewer.css +7 -249
  62. package/src/styles/openpress/reader-runtime.css +0 -274
  63. package/src/styles/openpress/slide-presenter.css +150 -0
  64. package/src/styles/openpress/slide-public-viewer.css +222 -0
  65. package/src/styles/openpress/workbench-dialog.css +267 -0
  66. package/src/styles/openpress/workbench-export.css +154 -0
  67. package/src/styles/openpress/workbench-inline-editor.css +128 -0
  68. package/src/styles/openpress/workbench-panels.css +0 -88
  69. package/src/styles/openpress/workbench-search.css +257 -0
  70. package/src/styles/openpress/workbench-toolbar.css +422 -0
  71. package/src/styles/openpress/workbench.css +34 -1263
  72. package/src/styles/openpress/workspace-gallery.css +0 -5
  73. package/src/styles/openpress.css +7 -1
  74. package/vite.config.ts +98 -25
  75. package/src/openpress/workbench/actions/ExportImageControl.tsx +0 -96
  76. package/src/styles/openpress/media-workspace.css +0 -230
@@ -75,9 +75,10 @@ async function renameProjectAsset({ config, kind, name, nextName }) {
75
75
  throw new Error("Rename requires a different valid name.");
76
76
  }
77
77
 
78
- const currentPath = resolveAssetPath(config, kind, normalizedCurrentName);
79
- const nextPath = resolveAssetPath(config, kind, normalizedNextName);
80
- await assertPathExists(currentPath, `${kind} asset not found: ${normalizedCurrentName}`);
78
+ const current = await resolveExistingAssetPath(config, kind, normalizedCurrentName);
79
+ if (!current) throw new Error(`${kind} asset not found: ${normalizedCurrentName}`);
80
+ const currentPath = current.path;
81
+ const nextPath = resolveAssetPathInRoot(current.root, kind, normalizedNextName);
81
82
  if (await fileExists(nextPath)) throw new Error(`${kind} asset already exists: ${normalizedNextName}`);
82
83
 
83
84
  await fs.rename(currentPath, nextPath);
@@ -113,8 +114,9 @@ async function deleteProjectAsset({ config, kind, name }) {
113
114
  };
114
115
  }
115
116
 
116
- const targetPath = resolveAssetPath(config, kind, normalizedName);
117
- await assertPathExists(targetPath, `${kind} asset not found: ${normalizedName}`);
117
+ const target = await resolveExistingAssetPath(config, kind, normalizedName);
118
+ if (!target) throw new Error(`${kind} asset not found: ${normalizedName}`);
119
+ const targetPath = target.path;
118
120
  await fs.rm(targetPath, { recursive: true, force: true });
119
121
 
120
122
  return {
@@ -242,8 +244,15 @@ async function findProjectAssetReferences({ config, kind, name }) {
242
244
  return references;
243
245
  }
244
246
 
245
- function resolveAssetPath(config, kind, name) {
246
- const root = kind === "media" ? config.paths.mediaDir : config.paths.componentsDir;
247
+ async function resolveExistingAssetPath(config, kind, name) {
248
+ for (const root of await assetRoots(config, kind)) {
249
+ const target = resolveAssetPathInRoot(root, kind, name);
250
+ if (await fileExists(target)) return { root, path: target };
251
+ }
252
+ return null;
253
+ }
254
+
255
+ function resolveAssetPathInRoot(root, kind, name) {
247
256
  const target = path.resolve(root, name);
248
257
  const resolvedRoot = path.resolve(root);
249
258
  if (!target.startsWith(`${resolvedRoot}${path.sep}`) && target !== resolvedRoot) {
@@ -252,6 +261,35 @@ function resolveAssetPath(config, kind, name) {
252
261
  return target;
253
262
  }
254
263
 
264
+ async function assetRoots(config, kind) {
265
+ const childName = kind === "media" ? "media" : "components";
266
+ const roots = [
267
+ kind === "media" ? config.paths.mediaDir : config.paths.componentsDir,
268
+ ];
269
+ try {
270
+ const entries = await fs.readdir(config.paths.documentRoot, { withFileTypes: true });
271
+ for (const entry of entries) {
272
+ if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "shared") continue;
273
+ roots.push(path.join(config.paths.documentRoot, entry.name, childName));
274
+ }
275
+ } catch {
276
+ // Missing press/ is reported by workspace validation/export.
277
+ }
278
+ return uniquePaths(roots);
279
+ }
280
+
281
+ function uniquePaths(paths) {
282
+ const out = [];
283
+ const seen = new Set();
284
+ for (const candidate of paths) {
285
+ const normalized = path.resolve(candidate);
286
+ if (seen.has(normalized)) continue;
287
+ seen.add(normalized);
288
+ out.push(normalized);
289
+ }
290
+ return out;
291
+ }
292
+
255
293
  function normalizeAssetName(kind, value, currentName = "") {
256
294
  if (kind === "media") return sanitizeMediaFileName(value, currentName);
257
295
  return sanitizeComponentName(value);
@@ -347,10 +385,6 @@ function normalizePositiveInteger(value) {
347
385
  return Number.isInteger(number) && number > 0 ? number : null;
348
386
  }
349
387
 
350
- async function assertPathExists(filePath, message) {
351
- if (!(await fileExists(filePath))) throw new Error(message);
352
- }
353
-
354
388
  async function fileExists(filePath) {
355
389
  try {
356
390
  await fs.access(filePath);
@@ -113,11 +113,11 @@ export function headingAttributesForBlock({
113
113
  }
114
114
 
115
115
  /**
116
- * Build a fallback single-entry outline when a section has no h2/h3 headings
116
+ * Build a section-title outline row when a section has no h2/h3 headings
117
117
  * the resolver could pick up. The outline still needs one row so the TOC
118
118
  * doesn't lose the section.
119
119
  */
120
- export function fallbackOutlineItems({ sourceId, section, chapterLabel, title, blocks }) {
120
+ export function sectionTitleOutlineItems({ sourceId, section, chapterLabel, title, blocks }) {
121
121
  const targetBlock = blocks[0];
122
122
  return [{
123
123
  id: `${sourceId}:${section.slug}`,
@@ -12,12 +12,12 @@ import path from "node:path";
12
12
  import React from "react";
13
13
  import { documentRelativePath, resolveDocumentRelativePath } from "../../runtime/path-utils.mjs";
14
14
  import { compileMdx } from "../mdx-compile.mjs";
15
- import { createHeadingState, fallbackOutlineItems, headingAttributesForBlock } from "./heading-numbering.mjs";
15
+ import { createHeadingState, headingAttributesForBlock, sectionTitleOutlineItems } from "./heading-numbering.mjs";
16
16
 
17
17
  const MDX_EXT = ".mdx";
18
18
 
19
19
  /**
20
- * Resolve all sources registered in `press/index.tsx`.
20
+ * Resolve all sources registered in `press/<slug>/press.tsx`.
21
21
  *
22
22
  * @param {object} opts
23
23
  * @param {Record<string, object>} opts.sources The raw `sources` export.
@@ -141,7 +141,7 @@ async function resolveSource({ sourceId, descriptor, documentRoot, globalCompone
141
141
  title: resolvedSectionTitle,
142
142
  meta: section.meta ?? {},
143
143
  });
144
- outline.push(...(outlineItems.length > 0 ? outlineItems : fallbackOutlineItems({
144
+ outline.push(...(outlineItems.length > 0 ? outlineItems : sectionTitleOutlineItems({
145
145
  sourceId,
146
146
  section,
147
147
  chapterLabel,
@@ -12,18 +12,19 @@ const COMPONENT_EXT = ".tsx";
12
12
  export async function discoverSectionStyles(root = ".", config = {}, { sectionRoots } = {}) {
13
13
  const workspaceRoot = path.resolve(root);
14
14
  const documentRoot = config.paths?.documentRoot ?? path.join(workspaceRoot, "press");
15
- const componentsRoot = config.paths?.componentsDir ?? path.join(documentRoot, "components");
16
- const globalComponents = await discoverComponents(componentsRoot, documentRoot, "global");
17
-
18
- // Multi-Press workspaces can place their chapters under per-Press
19
- // subfolders (e.g. press/userstory/chapters/, press/slidepack/chapters/).
20
- // The caller passes each Press's resolved section-folders root; we
21
- // discover sections in every root and merge. Duplicate paths are
22
- // de-duplicated by absolutePath. Falls back to the workspace-default
23
- // root (press/chapters/) when no roots are passed in.
15
+ const componentsRoot = config.paths?.componentsDir ?? path.join(documentRoot, "shared", "components");
16
+ const globalComponents = await discoverComponentsInRoots(
17
+ [componentsRoot],
18
+ documentRoot,
19
+ "global",
20
+ );
21
+
22
+ // The caller usually passes each Press's resolved section-folders root.
23
+ // When it does not, discover conventional `press/<slug>/chapters` roots.
24
+ // Duplicate paths are de-duplicated by absolutePath.
24
25
  const effectiveRoots = sectionRoots && sectionRoots.length > 0
25
26
  ? sectionRoots
26
- : [config.paths?.chaptersDir ?? config.paths?.sourceDir ?? path.join(documentRoot, "chapters")];
27
+ : await discoverConventionalChapterRoots(documentRoot);
27
28
  const seen = new Set();
28
29
  const sections = [];
29
30
  for (const sectionsRoot of effectiveRoots) {
@@ -40,11 +41,46 @@ export async function discoverSectionStyles(root = ".", config = {}, { sectionRo
40
41
  documentRoot,
41
42
  globalComponents,
42
43
  sections,
43
- // Back-compat: `chapters` alias for callers that still expect the old shape.
44
44
  chapters: sections,
45
45
  };
46
46
  }
47
47
 
48
+ async function discoverConventionalChapterRoots(documentRoot) {
49
+ const roots = [];
50
+ let entries = [];
51
+ try {
52
+ entries = await fs.readdir(documentRoot, { withFileTypes: true });
53
+ } catch (error) {
54
+ if (error?.code === "ENOENT") return roots;
55
+ throw error;
56
+ }
57
+ for (const entry of entries) {
58
+ if (!entry.isDirectory() || entry.name === "shared" || entry.name.startsWith(".")) continue;
59
+ const chaptersRoot = path.join(documentRoot, entry.name, "chapters");
60
+ try {
61
+ const stat = await fs.stat(chaptersRoot);
62
+ if (stat.isDirectory()) roots.push(chaptersRoot);
63
+ } catch (error) {
64
+ if (error?.code !== "ENOENT") throw error;
65
+ }
66
+ }
67
+ return roots;
68
+ }
69
+
70
+ export async function discoverComponentsInRoots(componentRoots, documentRoot, scope = "global") {
71
+ const components = [];
72
+ const seenPaths = new Set();
73
+ for (const componentsDir of uniquePaths(componentRoots)) {
74
+ const found = await discoverComponents(componentsDir, documentRoot, scope);
75
+ for (const component of found) {
76
+ if (seenPaths.has(component.absolutePath)) continue;
77
+ seenPaths.add(component.absolutePath);
78
+ components.push(component);
79
+ }
80
+ }
81
+ return components;
82
+ }
83
+
48
84
  async function discoverSections(documentRoot, sectionsDir) {
49
85
  const entries = await readDirectoryEntries(sectionsDir);
50
86
  const sectionDirs = entries.filter((entry) => entry.isDirectory()).sort(compareSectionDirectories);
@@ -149,6 +185,19 @@ async function readDirectoryEntries(directory) {
149
185
  }
150
186
  }
151
187
 
188
+ function uniquePaths(paths) {
189
+ const out = [];
190
+ const seen = new Set();
191
+ for (const candidate of paths ?? []) {
192
+ if (!candidate) continue;
193
+ const normalized = path.resolve(candidate);
194
+ if (seen.has(normalized)) continue;
195
+ seen.add(normalized);
196
+ out.push(normalized);
197
+ }
198
+ return out;
199
+ }
200
+
152
201
  async function fileExists(filePath) {
153
202
  try {
154
203
  const stat = await fs.stat(filePath);
@@ -29,15 +29,14 @@ export function textSourceTransformPlugin({ workspaceRoot, documentRoot }) {
29
29
  };
30
30
  }
31
31
 
32
- export function addLiteralTextSourceProps(code, { filePath = "index.tsx", sourcePath = "press/index.tsx" } = {}) {
32
+ export function addLiteralTextSourceProps(code, { filePath = "press/press.tsx", sourcePath = "press/<name>/press.tsx" } = {}) {
33
33
  const sourceFile = ts.createSourceFile(filePath, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
34
34
  const textRefs = collectOpenPressTextRefs(sourceFile);
35
- if (textRefs.identifiers.size === 0 && textRefs.namespaces.size === 0) return code;
36
35
 
37
36
  const insertions = [];
38
37
 
39
38
  const visit = (node) => {
40
- if (ts.isJsxElement(node) && isTextElementName(node.openingElement.tagName, textRefs)) {
39
+ if (ts.isJsxElement(node) && isSourceBackedTextElement(node.openingElement, textRefs)) {
41
40
  const opening = node.openingElement;
42
41
  if (!hasJsxAttribute(opening, "source")) {
43
42
  const literal = literalTextChildRange(node, sourceFile, code);
@@ -133,13 +132,28 @@ function sourcePropExpression({ sourcePath, objectId, range }) {
133
132
  return `{ ${props.join(", ")} }`;
134
133
  }
135
134
 
135
+ function isSourceBackedTextElement(opening, refs) {
136
+ if (isTextElementName(opening.tagName, refs)) return true;
137
+ return hasJsxAttribute(opening, "objectId") && isComponentElementName(opening.tagName);
138
+ }
139
+
136
140
  function isTextElementName(name, refs) {
137
141
  if (ts.isIdentifier(name)) return refs.identifiers.has(name.text);
138
- if (!ts.isJsxMemberExpression(name)) return false;
142
+ if (!isJsxMemberExpression(name)) return false;
139
143
  if (name.name.text !== "Text") return false;
140
144
  return ts.isIdentifier(name.expression) && refs.namespaces.has(name.expression.text);
141
145
  }
142
146
 
147
+ function isComponentElementName(name) {
148
+ if (ts.isIdentifier(name)) return /^[A-Z]/.test(name.text);
149
+ if (isJsxMemberExpression(name)) return isComponentElementName(name.expression);
150
+ return false;
151
+ }
152
+
153
+ function isJsxMemberExpression(node) {
154
+ return ts.isPropertyAccessExpression(node) || node?.kind === ts.SyntaxKind.JsxMemberExpression;
155
+ }
156
+
143
157
  function hasJsxAttribute(opening, name) {
144
158
  return opening.attributes.properties.some((prop) =>
145
159
  ts.isJsxAttribute(prop) && prop.name.text === name
@@ -8,11 +8,11 @@ const DEFAULT_CONFIG = {
8
8
  organization: "",
9
9
  workspaceLabel: "",
10
10
  documentDir: ".",
11
- sourceDir: "content",
12
- mediaDir: "media",
13
- themeDir: "theme",
11
+ sourceDir: ".",
12
+ mediaDir: "shared/media",
13
+ themeDir: "shared/theme",
14
14
  designDoc: "design.md",
15
- componentsDir: "components",
15
+ componentsDir: "shared/components",
16
16
  publicDir: "public/openpress",
17
17
  outputDir: "dist",
18
18
  captionNumbering: {
@@ -66,10 +66,10 @@ async function readPackageOpenpressField(workspaceRoot) {
66
66
  // project isn't an OpenPress workspace.
67
67
  const CONVENTION = {
68
68
  documentDir: "press",
69
- sourceDir: "chapters",
70
- mediaDir: "media",
71
- themeDir: "theme",
72
- componentsDir: "components",
69
+ sourceDir: ".",
70
+ mediaDir: "shared/media",
71
+ themeDir: "shared/theme",
72
+ componentsDir: "shared/components",
73
73
  designDoc: "design.md",
74
74
  publicDir: "public/openpress",
75
75
  outputDir: "dist-react",
@@ -130,39 +130,39 @@ export function publicPdfHref(config) {
130
130
  return `/${config.pdf.filename}`;
131
131
  }
132
132
 
133
- function stringValue(value, fallback) {
134
- return typeof value === "string" && value.trim() ? value.trim() : fallback;
133
+ function stringValue(value, defaultValue) {
134
+ return typeof value === "string" && value.trim() ? value.trim() : defaultValue;
135
135
  }
136
136
 
137
- function optionalStringValue(value, fallback) {
137
+ function optionalStringValue(value, defaultValue) {
138
138
  if (value === null) return null;
139
139
  if (typeof value === "string" && value.trim()) return value.trim();
140
- return fallback;
140
+ return defaultValue;
141
141
  }
142
142
 
143
- function captionNumberingValue(value, fallback) {
143
+ function captionNumberingValue(value, defaults) {
144
144
  const input = value && typeof value === "object" && !Array.isArray(value) ? value : {};
145
145
  return {
146
- figure: optionalStringValue(input.figure, fallback.figure) ?? fallback.figure,
147
- table: optionalStringValue(input.table, fallback.table) ?? fallback.table,
148
- separator: typeof input.separator === "string" ? input.separator : fallback.separator,
146
+ figure: optionalStringValue(input.figure, defaults.figure) ?? defaults.figure,
147
+ table: optionalStringValue(input.table, defaults.table) ?? defaults.table,
148
+ separator: typeof input.separator === "string" ? input.separator : defaults.separator,
149
149
  };
150
150
  }
151
151
 
152
- function booleanValue(value, fallback) {
153
- return typeof value === "boolean" ? value : fallback;
152
+ function booleanValue(value, defaultValue) {
153
+ return typeof value === "boolean" ? value : defaultValue;
154
154
  }
155
155
 
156
- function fileNameValue(value, fallback) {
157
- const fileName = stringValue(value, fallback);
156
+ function fileNameValue(value, defaultValue) {
157
+ const fileName = stringValue(value, defaultValue);
158
158
  if (fileName.includes("/") || fileName.includes("\\") || fileName === "." || fileName === "..") {
159
159
  throw new Error(`OpenPress config pdf.filename must be a file name, got: ${fileName}`);
160
160
  }
161
161
  return fileName;
162
162
  }
163
163
 
164
- function relativePathValue(value, fallback) {
165
- const raw = stringValue(value, fallback).replaceAll("\\", "/");
164
+ function relativePathValue(value, defaultValue) {
165
+ const raw = stringValue(value, defaultValue).replaceAll("\\", "/");
166
166
  if (path.isAbsolute(raw)) throw new Error(`OpenPress config paths must be relative, got: ${raw}`);
167
167
  const normalized = path.posix.normalize(raw).replace(/^\.\//, "");
168
168
  if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) {
@@ -21,55 +21,69 @@ export async function copyDirectory(src, dst) {
21
21
  await fs.cp(src, dst, { recursive: true });
22
22
  }
23
23
 
24
- export async function writeContentCss(root, targetDir, config) {
24
+ export async function writeContentCss(root, targetDir, config, options = {}) {
25
25
  config ??= await loadConfig(root);
26
- const css = await buildContentCss(root, config);
26
+ const css = await buildContentCss(root, config, options);
27
27
  await fs.mkdir(targetDir, { recursive: true });
28
28
  await fs.writeFile(path.join(targetDir, "content.css"), css, "utf8");
29
29
  }
30
30
 
31
- export async function buildContentCss(root, config) {
31
+ export async function buildContentCss(root, config, options = {}) {
32
32
  config ??= await loadConfig(root);
33
- const contentAssetsDir = config.paths.themeDir;
33
+ const sharedThemeDir = config.paths.themeDir;
34
34
  const parts = [];
35
35
  for (const layer of CONTENT_CSS_LAYERS) {
36
36
  if (typeof layer !== "string" && layer.type === "directory") {
37
- await appendCssDirectory(parts, path.join(contentAssetsDir, layer.path), layer.path, {
37
+ await appendCssDirectory(parts, path.join(sharedThemeDir, layer.path), layer.path, {
38
38
  exclude: new Set(layer.exclude ?? []),
39
39
  });
40
40
  continue;
41
41
  }
42
42
  const relativePath = typeof layer === "string" ? layer : layer.path;
43
- const cssPath = path.join(contentAssetsDir, relativePath);
43
+ const cssPath = path.join(sharedThemeDir, relativePath);
44
44
  let css;
45
45
  try {
46
46
  css = await fs.readFile(cssPath, "utf8");
47
47
  } catch (error) {
48
- if (typeof layer !== "string" && layer.optional && error.code === "ENOENT") continue;
48
+ if (error.code === "ENOENT") continue;
49
49
  throw error;
50
50
  }
51
51
  parts.push(`/* === ${relativePath} === */\n`);
52
52
  parts.push(css.trimEnd());
53
53
  parts.push("\n\n");
54
54
  }
55
+ const themeRoots = uniquePaths([
56
+ ...(await discoverPressChildRoots(config.paths.documentRoot, "theme")),
57
+ ...(options.themeRoots ?? []),
58
+ ]);
59
+ for (const themeRoot of themeRoots) {
60
+ await appendCssDirectory(parts, themeRoot, documentRelativeLabel(themeRoot, config.paths.documentRoot));
61
+ }
55
62
  parts.push("/* === engine/katex.css === */\n");
56
63
  parts.push((await readKatexCss()).trimEnd());
57
64
  parts.push("\n\n");
58
65
  return parts.join("");
59
66
  }
60
67
 
61
- export async function writeComponentsCss(root, targetDir, config) {
68
+ export async function writeComponentsCss(root, targetDir, config, options = {}) {
62
69
  config ??= await loadConfig(root);
63
- const css = await buildComponentsCss(root, config);
70
+ const css = await buildComponentsCss(root, config, options);
64
71
  await fs.mkdir(targetDir, { recursive: true });
65
72
  await fs.writeFile(path.join(targetDir, "components.css"), css, "utf8");
66
73
  }
67
74
 
68
- export async function buildComponentsCss(root, config) {
75
+ export async function buildComponentsCss(root, config, options = {}) {
69
76
  config ??= await loadConfig(root);
70
77
  const parts = [];
71
78
  await appendCssDirectory(parts, path.join(config.paths.themeDir, "patterns"), "theme/patterns");
72
- await appendComponentScopedCss(parts, config.paths.componentsDir);
79
+ const componentRoots = uniquePaths([
80
+ config.paths.componentsDir,
81
+ ...(await discoverPressChildRoots(config.paths.documentRoot, "components")),
82
+ ...(options.componentRoots ?? []),
83
+ ]);
84
+ for (const componentsDir of componentRoots) {
85
+ await appendComponentScopedCss(parts, componentsDir, documentRelativeLabel(componentsDir, config.paths.documentRoot));
86
+ }
73
87
  return parts.join("");
74
88
  }
75
89
 
@@ -88,7 +102,7 @@ async function appendCssDirectory(parts, directory, labelPrefix, options = {}) {
88
102
  }
89
103
  }
90
104
 
91
- async function appendComponentScopedCss(parts, componentsDir) {
105
+ async function appendComponentScopedCss(parts, componentsDir, labelPrefix = "components") {
92
106
  let entries;
93
107
  try {
94
108
  entries = await fs.readdir(componentsDir, { withFileTypes: true });
@@ -107,8 +121,38 @@ async function appendComponentScopedCss(parts, componentsDir) {
107
121
  }
108
122
  throw error;
109
123
  }
110
- parts.push(`/* === components/${entry.name}/style.css === */\n`);
124
+ parts.push(`/* === ${labelPrefix}/${entry.name}/style.css === */\n`);
111
125
  parts.push(css.trimEnd());
112
126
  parts.push("\n\n");
113
127
  }
114
128
  }
129
+
130
+ async function discoverPressChildRoots(documentRoot, childName) {
131
+ let entries;
132
+ try {
133
+ entries = await fs.readdir(documentRoot, { withFileTypes: true });
134
+ } catch {
135
+ return [];
136
+ }
137
+ return entries
138
+ .filter((entry) => entry.isDirectory() && entry.name !== "shared" && !entry.name.startsWith("."))
139
+ .map((entry) => path.join(documentRoot, entry.name, childName));
140
+ }
141
+
142
+ function uniquePaths(paths) {
143
+ const out = [];
144
+ const seen = new Set();
145
+ for (const candidate of paths ?? []) {
146
+ if (!candidate) continue;
147
+ const normalized = path.resolve(candidate);
148
+ if (seen.has(normalized)) continue;
149
+ seen.add(normalized);
150
+ out.push(normalized);
151
+ }
152
+ return out;
153
+ }
154
+
155
+ function documentRelativeLabel(filePath, documentRoot) {
156
+ const relative = path.relative(documentRoot, filePath).split(path.sep).join("/");
157
+ return relative && !relative.startsWith("..") ? relative : path.basename(filePath);
158
+ }
@@ -5,6 +5,7 @@ import { buildReactStatic, startStaticServer } from "../commands/_shared.mjs";
5
5
  import { walkFiles } from "./file-walk.mjs";
6
6
  import { createIssue, createIssueReport } from "./issue-report.mjs";
7
7
  import { collectActiveContentFiles, resolveActiveSourceWorkspace } from "./source-workspace.mjs";
8
+ import { collectSourceTextFiles } from "./source-text-tools.mjs";
8
9
 
9
10
  const MEDIA_EXTENSIONS = new Set([".avif", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"]);
10
11
  const SOURCE_EXTENSIONS = new Set([".css", ".html", ".js", ".json", ".md", ".mjs", ".ts", ".tsx"]);
@@ -27,7 +28,7 @@ export async function inspectWorkspace({ root, config, options = {}, recurse = n
27
28
  });
28
29
 
29
30
  checked.push("media");
30
- const mediaFiles = await readMediaFiles(sourceScan.config.paths.mediaDir);
31
+ const mediaFiles = await readMediaFiles(await collectInspectionMediaRoots(sourceScan.config));
31
32
  const sourceText = sourceScan.sourceFiles.map((file) => file.text).join("\n");
32
33
  const unusedMedia = mediaFiles.filter((file) => !sourceText.includes(file.name) && !sourceText.includes(file.relativePath));
33
34
  unusedMedia.forEach((file) => {
@@ -99,11 +100,7 @@ export async function collectInspectionSources(config) {
99
100
  const sourceWorkspace = await resolveActiveSourceWorkspace(config);
100
101
  const sourceConfig = sourceWorkspace.config;
101
102
  const contentFiles = await collectActiveContentFiles(sourceWorkspace);
102
- const sourceFiles = [
103
- ...contentFiles,
104
- ...await readSourceFiles(sourceConfig.paths.componentsDir),
105
- ...await readSingleFile(sourceConfig.paths.designDoc),
106
- ];
103
+ const sourceFiles = await collectSourceTextFiles(sourceConfig, { scope: "all" });
107
104
  const componentUsage = summarizeComponentUsage(contentFiles);
108
105
 
109
106
  return {
@@ -260,20 +257,48 @@ async function readSingleFile(absolutePath) {
260
257
  }
261
258
  }
262
259
 
263
- async function readMediaFiles(directory) {
260
+ async function readMediaFiles(directories) {
264
261
  const files = [];
265
- await walkFiles(directory, async (absolutePath) => {
266
- if (!MEDIA_EXTENSIONS.has(path.extname(absolutePath).toLowerCase())) return;
267
- files.push({
268
- absolutePath,
269
- name: path.basename(absolutePath),
270
- relativePath: path.relative(directory, absolutePath).replaceAll("\\", "/"),
262
+ for (const directory of directories) {
263
+ await walkFiles(directory, async (absolutePath) => {
264
+ if (!MEDIA_EXTENSIONS.has(path.extname(absolutePath).toLowerCase())) return;
265
+ files.push({
266
+ absolutePath,
267
+ name: path.basename(absolutePath),
268
+ relativePath: path.relative(directory, absolutePath).replaceAll("\\", "/"),
269
+ });
271
270
  });
272
- });
271
+ }
273
272
  files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
274
273
  return files;
275
274
  }
276
275
 
276
+ async function collectInspectionMediaRoots(config) {
277
+ const roots = [config.paths.mediaDir];
278
+ try {
279
+ const entries = await fs.readdir(config.paths.documentRoot, { withFileTypes: true });
280
+ for (const entry of entries) {
281
+ if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "shared") continue;
282
+ roots.push(path.join(config.paths.documentRoot, entry.name, "media"));
283
+ }
284
+ } catch {
285
+ // Missing press/ is reported by validation/export.
286
+ }
287
+ return uniquePaths(roots);
288
+ }
289
+
290
+ function uniquePaths(paths) {
291
+ const out = [];
292
+ const seen = new Set();
293
+ for (const candidate of paths) {
294
+ const normalized = path.resolve(candidate);
295
+ if (seen.has(normalized)) continue;
296
+ seen.add(normalized);
297
+ out.push(normalized);
298
+ }
299
+ return out;
300
+ }
301
+
277
302
  function summarizeComponentUsage(contentFiles) {
278
303
  const usages = new Map();
279
304
  for (const file of contentFiles) {
@@ -373,7 +398,7 @@ function inspectionExpression() {
373
398
  });
374
399
  return {
375
400
  pageNumber: index + 1,
376
- title: page.getAttribute('data-page-title') || wrapper.getAttribute('aria-label') || '',
401
+ title: page.getAttribute('data-page-title') || page.getAttribute('data-openpress-frame-key') || wrapper.getAttribute('aria-label') || '',
377
402
  source: sourcePath ? { file: sourceFile, path: sourcePath } : undefined,
378
403
  overflows,
379
404
  };
@@ -85,8 +85,8 @@ function pageGeometry({ id, label, width, height }) {
85
85
  };
86
86
  }
87
87
 
88
- function cssLengthValue(value, fallback) {
89
- if (value == null) return fallback ?? null;
88
+ function cssLengthValue(value, defaultValue) {
89
+ if (value == null) return defaultValue ?? null;
90
90
  if (typeof value !== "string" || !value.trim()) {
91
91
  throw new Error("OpenPress page width/height must be CSS length strings.");
92
92
  }
@@ -97,16 +97,16 @@ function cssLengthValue(value, fallback) {
97
97
  return trimmed;
98
98
  }
99
99
 
100
- function optionalId(value, fallback) {
101
- if (value == null) return fallback;
100
+ function optionalId(value, defaultValue) {
101
+ if (value == null) return defaultValue;
102
102
  if (typeof value !== "string" || !/^[a-z0-9][a-z0-9-]*$/i.test(value)) {
103
103
  throw new Error("OpenPress page id must be a simple slug.");
104
104
  }
105
105
  return value;
106
106
  }
107
107
 
108
- function optionalLabel(value, fallback) {
109
- if (value == null) return fallback;
108
+ function optionalLabel(value, defaultValue) {
109
+ if (value == null) return defaultValue;
110
110
  if (typeof value !== "string" || !value.trim()) {
111
111
  throw new Error("OpenPress page label must be a non-empty string.");
112
112
  }