diff --git a/core/app/components/ContextEditor/ContextEditorClient.tsx b/core/app/components/ContextEditor/ContextEditorClient.tsx
index 681d1c9dc7..470a68712a 100644
--- a/core/app/components/ContextEditor/ContextEditorClient.tsx
+++ b/core/app/components/ContextEditor/ContextEditorClient.tsx
@@ -1,4 +1,4 @@
-import type { ContextEditorProps } from "context-editor"
+import type { ContextEditorProps, EditorDisplayMode, EditorPaneMode } from "context-editor"
import type { PubsId, PubTypes, PubTypesId } from "db/public"
import { useCallback, useMemo } from "react"
@@ -17,14 +17,20 @@ import { client } from "~/lib/api"
import { useServerAction } from "~/lib/serverActions"
import { useCommunity } from "../providers/CommunityProvider"
+const editorSkeleton = (
+
+
+
+)
+
const ContextEditor = dynamic(() => import("context-editor").then((mod) => mod.ContextEditor), {
ssr: false,
- // make sure this is the same height as the context editor, otherwise looks ugly
- loading: () => (
-
-
-
- ),
+ loading: () => editorSkeleton,
+})
+
+const EditorLayout = dynamic(() => import("context-editor").then((mod) => mod.EditorLayout), {
+ ssr: false,
+ loading: () => editorSkeleton,
})
export const ContextEditorClient = (
@@ -32,6 +38,10 @@ export const ContextEditorClient = (
pubTypes: Pick[]
pubId: PubsId
pubTypeId: PubTypesId
+ /** When true, wraps the editor in EditorLayout with fullscreen + preview controls. */
+ withLayout?: boolean
+ initialDisplay?: EditorDisplayMode
+ initialPanes?: EditorPaneMode
// Might be able to use more of this type in the future—for now, this component is a lil more stricty typed than context-editor
} & Pick<
ContextEditorProps,
@@ -76,26 +86,32 @@ export const ContextEditorClient = (
)
const memoEditor = useMemo(() => {
- return (
- {
- return {}
- }}
- atomRenderingComponent={ContextAtom}
- onChange={props.onChange}
- initialDoc={props.initialDoc}
- disabled={props.disabled}
- className={props.className}
- hideMenu={props.hideMenu}
- upload={signedUploadUrl}
- getterRef={props.getterRef}
- />
- )
+ const sharedProps = {
+ pubId: props.pubId,
+ pubTypeId: props.pubTypeId,
+ pubTypes: props.pubTypes,
+ // debounce returns `undefined` at the beginning — safe to cast
+ getPubs: debouncedGetPubs as ContextEditorProps["getPubs"],
+ getPubById: () => ({}),
+ atomRenderingComponent: ContextAtom,
+ onChange: props.onChange,
+ initialDoc: props.initialDoc,
+ disabled: props.disabled,
+ className: props.className,
+ hideMenu: props.hideMenu,
+ upload: signedUploadUrl,
+ getterRef: props.getterRef,
+ }
+ if (props.withLayout) {
+ return (
+
+ )
+ }
+ return
}, [
props.pubTypes,
props.disabled,
@@ -106,6 +122,9 @@ export const ContextEditorClient = (
props.onChange,
props.pubId,
props.pubTypeId,
+ props.withLayout,
+ props.initialDisplay,
+ props.initialPanes,
signedUploadUrl,
])
diff --git a/core/app/components/forms/elements/ContextEditorElement.tsx b/core/app/components/forms/elements/ContextEditorElement.tsx
index a5d0952bda..fbdbab978b 100644
--- a/core/app/components/forms/elements/ContextEditorElement.tsx
+++ b/core/app/components/forms/elements/ContextEditorElement.tsx
@@ -109,8 +109,9 @@ const EditorFormElement = function EditorFormElement({
pubTypeId={pubTypeId}
initialDoc={initialDoc}
disabled={disabled}
- className="h-96 overflow-scroll"
+ className="h-96 overflow-scroll rounded-md border"
onChange={handleChange}
+ withLayout
/>
diff --git a/development/features/myst-integration.md b/development/features/myst-integration.md
new file mode 100644
index 0000000000..30f48a4f3e
--- /dev/null
+++ b/development/features/myst-integration.md
@@ -0,0 +1,168 @@
+# MyST Markdown Integration
+
+## Overview
+
+Integrate MyST (Markedly Structured Text) into the PubPub platform to provide a structured, extensible authoring format for scholarly content. MyST extends CommonMark with roles (inline markup) and directives (block-level components), making it well-suited for academic publishing workflows with cross-references, citations, math, and embedded metadata.
+
+This feature is broken into four phases. Each phase is independently shippable and builds on the previous.
+
+---
+
+## Phase 1: Fullscreen + Side-by-Side Editing and Preview
+
+**Goal:** Improve the context editor's editing experience with a fullscreen mode and live preview panel, establishing the UI patterns that MyST authoring will use.
+
+### Scope
+
+- Fullscreen editing mode for the context editor (ProseMirror)
+- Side-by-side layout: editor on the left, rendered preview on the right
+- Toggle between fullscreen/inline and editor-only/split/preview-only views
+- Responsive behavior (collapse to tabbed on small screens)
+
+### Notes
+
+- The `EditorDash` storybook component already demonstrates a multi-panel layout with JSON, pubs, and site preview panels. This phase productionizes and extends that pattern.
+- The preview pane will initially render the ProseMirror document as HTML. In Phase 2 it will also support MyST source rendering.
+- Consider whether the fullscreen editor should be a modal overlay or a route-level layout.
+
+### Dependencies
+
+- None (works with existing context editor)
+
+### Implementation Plan
+
+**Approach:** CSS-based fullscreen overlay (fixed-inset container + Escape keybinding + body scroll lock), rather than Radix `Dialog` or a route-level layout. Keeps Phase 1 self-contained inside `packages/context-editor` plus a toggle in the form element, avoids new routing work, and stays reusable for Phase 2's MyST source mode. Tradeoff: no URL/deep-link story for fullscreen — acceptable for a UI-only phase. Radix `Dialog` was considered and rejected because its portal would remount the ProseMirror subtree on every toggle, dropping undo history and cursor position.
+
+**Key anchors in current code:**
+
+- `ContextEditor` — `packages/context-editor/src/ContextEditor.tsx:82` (top-level PM editor, single-column today)
+- `ContextEditorElement` — `core/app/components/forms/elements/ContextEditorElement.tsx:40` (react-hook-form mount point on the pub edit page)
+- `prosemirrorToHTML()` — `packages/context-editor/src/utils/serialize.ts:10` (existing PM → HTML serializer; preview reuses this)
+- `EditorDash` storybook — `packages/context-editor/src/stories/EditorDash/EditorDash.tsx` (prior-art multi-panel layout with `SitePanel` preview; this phase productionizes the pattern)
+- UI primitives: Radix-based shadcn (`Dialog`, `Sheet`, `Popover`) in `packages/ui`. No resizable-pane primitive exists in the repo — use Tailwind grid/flex.
+
+**Steps:**
+
+1. New `EditorLayout` component in `packages/context-editor` wrapping `ContextEditor`. Owns two pieces of view state:
+ - `display: "inline" | "fullscreen"`
+ - `panes: "editor" | "split" | "preview"`
+2. `PreviewPanel` component that calls `prosemirrorToHTML()` on editor state changes, debounced. Initially renders HTML into a styled container; no MyST yet (Phase 2).
+3. Split layout via Tailwind `md:grid-cols-2`; no external resize library. Fixed 50/50 split unless a draggable divider proves necessary.
+4. Fullscreen toggle button in `ContextEditorElement` (and/or `MenuBar`); fullscreen mode mounts `EditorLayout` inside `Dialog` sized to viewport.
+5. Responsive behavior: below `md`, collapse split view to a tab switcher (editor / preview).
+
+**Out of scope for Phase 1:**
+
+- MyST source editing or rendering (Phase 2)
+- Storing preview state or persisting pane layout across sessions
+- Route-level fullscreen / deep-linkable editor URLs
+
+---
+
+## Phase 2: MyST Authoring — Basic Styling, Preview, and Rendering
+
+**Goal:** Allow authors to write and preview MyST markdown within the platform, with correct rendering of standard MyST constructs.
+
+### Scope
+
+- MyST source editing mode (CodeMirror with MyST syntax highlighting)
+- Toggle between ProseMirror WYSIWYG and MyST source modes
+- Live preview rendering of MyST content using the `mystmd` toolchain (parse to AST, render to HTML)
+- Support for standard MyST constructs:
+ - Directives: admonitions, figures, code blocks, math, tables
+ - Roles: inline math, cross-references, citations, abbreviations
+ - Frontmatter (title, authors, affiliations, etc.)
+- Styling: base theme for rendered MyST output consistent with PubPub's design system
+- Storage: determine whether MyST source is stored alongside or instead of ProseMirror HTML (likely a new `MyST` schema type for pub fields)
+
+### Open Questions
+
+- **Roundtrip fidelity:** Can we convert between ProseMirror doc and MyST losslessly, or are they separate content tracks?
+- **Storage format:** Store MyST source as plaintext and render on demand? Or store the parsed AST?
+- **Dependency management:** `mystmd` is a Node.js toolchain. Rendering could happen client-side, server-side, or in the site builder.
+
+### Dependencies
+
+- Phase 1 (fullscreen/preview layout)
+
+---
+
+## Phase 3: Custom Directives and Pub Includes
+
+**Goal:** Extend MyST with PubPub-specific directives that reference pubs, pub fields, and community data — enabling structured, data-driven documents.
+
+### Scope
+
+- Custom MyST directives for embedding pub data:
+ ```
+ :::{pub}
+ :field: title
+ :::
+ ```
+ or inline roles: `` {pub:field}`slug:title` ``
+- Pub include/transclusion: embed one pub's content within another document
+- Field value interpolation: reference pub field values in MyST templates (similar to existing `$.pub.values` interpolation in site builder templates)
+- Directive registration API: allow communities to define custom directives backed by pub types
+- Autocompletion in the MyST source editor for directive names, pub slugs, and field names
+
+### Design Considerations
+
+- This overlaps with the existing `contextAtom`/`contextDoc` ProseMirror nodes. Those embed pubs in the WYSIWYG editor; these directives would be the MyST-source equivalent.
+- The existing remark-based markdown pipeline (`renderMarkdownWithPub.ts`) already supports custom directives (`:value{field=...}`, `:link{...}`). Consider aligning the MyST directive syntax with this existing pattern.
+- Directive resolution should work both at edit-time (preview) and at build-time (site builder).
+
+### Dependencies
+
+- Phase 2 (MyST rendering pipeline)
+
+---
+
+## Phase 4: Site Builder Integration
+
+**Goal:** Use MyST as a first-class template and content format in the site builder, enabling communities to build sites from MyST-authored content.
+
+### Scope
+
+- Site builder can consume MyST source from pub fields and render to HTML pages
+- MyST templates: page templates written in MyST (with directives for layout, navigation, pub listings)
+- Cross-references resolved across pubs within a site build (e.g., citation links between articles)
+- Output formats beyond HTML: PDF (via Typst/LaTeX), JATS XML for journal submission
+- Integration with the existing JSONata-based page group system:
+ - MyST content as the `transform` expression output
+ - Or: MyST templates as an alternative to JSONata transforms for content-heavy pages
+
+### Design Considerations
+
+- The `mystmd` CLI already supports multi-document projects with cross-references, TOC generation, and export to HTML/PDF/JATS. Evaluate whether the site builder should shell out to `mystmd` or use the JS API directly.
+- MyST's structured AST (`myst-spec`) could serve as an intermediate representation between pub content and final output, replacing or complementing the current HTML-centric pipeline.
+- Consider incremental builds: MyST's dependency graph (cross-references, includes) could inform which pages need rebuilding.
+
+### Dependencies
+
+- Phase 3 (custom directives for pub data)
+- Site builder 2 architecture (core sends pub IDs + templates, builder fetches and renders)
+
+---
+
+## Cross-Cutting Concerns
+
+### Content Model
+
+The current content pipeline is: **ProseMirror doc -> HTML -> stored in DB -> served/rendered**. MyST introduces an alternative track: **MyST source -> AST -> HTML/PDF/JATS**. Key decisions:
+
+- Do we support both tracks per field, or is it a per-field-type choice?
+- Is the ProseMirror schema extended to represent MyST constructs (bidirectional), or are they parallel formats?
+
+### Migration
+
+- Existing ProseMirror content should continue working as-is.
+- Consider a one-way export: ProseMirror doc -> MyST source (for authors who want to switch).
+
+### Performance
+
+- MyST parsing/rendering is non-trivial. Cache parsed ASTs where possible.
+- Preview rendering should be debounced and potentially run in a web worker.
+
+### Extensibility
+
+- MyST's directive/role system is inherently extensible. Define a clear boundary between "standard MyST," "PubPub built-in directives," and "community-defined directives."
diff --git a/packages/context-editor/.storybook/preview.ts b/packages/context-editor/.storybook/preview.ts
index bf191d8844..8dd1cb60f6 100644
--- a/packages/context-editor/.storybook/preview.ts
+++ b/packages/context-editor/.storybook/preview.ts
@@ -1,5 +1,6 @@
import type { Preview } from "@storybook/react"
+import "@pubpub/tailwind/style.css"
import "../src/tailwind.css"
import "../src/style.css"
diff --git a/packages/context-editor/package.json b/packages/context-editor/package.json
index 5e588a1078..f70827daad 100644
--- a/packages/context-editor/package.json
+++ b/packages/context-editor/package.json
@@ -113,6 +113,7 @@
"react-dom": "catalog:react19",
"react-hook-form": "catalog:",
"react-reconciler": "catalog:react19",
+ "@pubpub/tailwind": "workspace:*",
"schemas": "workspace:*",
"ui": "workspace:*",
"utils": "workspace:*",
diff --git a/packages/context-editor/src/ContextEditor.tsx b/packages/context-editor/src/ContextEditor.tsx
index 3e548af6ac..a2c67d9b72 100644
--- a/packages/context-editor/src/ContextEditor.tsx
+++ b/packages/context-editor/src/ContextEditor.tsx
@@ -14,6 +14,7 @@ import type { Node } from "prosemirror-model"
import type { ForwardRefExoticComponent, RefAttributes, RefObject } from "react"
import { useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from "react"
+import { createPortal } from "react-dom"
import { ProseMirror, ProseMirrorDoc, reactKeys } from "@handlewithcare/react-prosemirror"
import { EditorState } from "prosemirror-state"
import { fixTables } from "prosemirror-tables"
@@ -55,6 +56,14 @@ export interface ContextEditorProps {
hideMenu?: boolean
upload: (fileName: string) => Promise
+ /**
+ * When provided, the formatting menu is portaled into this DOM node instead of
+ * rendering inline at the top of the editor. Used by `EditorLayout` to host a
+ * full-width toolbar above a split editor/preview. The menu still lives inside
+ * the ProseMirror React context, so its hooks continue to work.
+ */
+ toolbarContainer?: Element | null
+
/**
* Ref to the context editor getter
* Allows you to retrieve the current state of the editor from the parent component,
@@ -141,7 +150,9 @@ const ContextEditor = (props: ContextEditorProps) => {
editable={() => !props.disabled}
className={cn("font-serif", props.className)}
>
- {props.hideMenu ? null : (
+ {props.hideMenu ? null : props.toolbarContainer ? (
+ createPortal(, props.toolbarContainer)
+ ) : (