Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 46 additions & 27 deletions core/app/components/ContextEditor/ContextEditorClient.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -17,21 +17,31 @@ import { client } from "~/lib/api"
import { useServerAction } from "~/lib/serverActions"
import { useCommunity } from "../providers/CommunityProvider"

const editorSkeleton = (
<Skeleton className="h-[440px] w-full">
<Skeleton className="h-14 w-full rounded-b-none" />
</Skeleton>
)

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: () => (
<Skeleton className="h-[440px] w-full">
<Skeleton className="h-14 w-full rounded-b-none" />
</Skeleton>
),
loading: () => editorSkeleton,
})

const EditorLayout = dynamic(() => import("context-editor").then((mod) => mod.EditorLayout), {
ssr: false,
loading: () => editorSkeleton,
})

export const ContextEditorClient = (
props: {
pubTypes: Pick<PubTypes, "id" | "name">[]
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,
Expand Down Expand Up @@ -76,26 +86,32 @@ export const ContextEditorClient = (
)

const memoEditor = useMemo(() => {
return (
<ContextEditor
pubId={props.pubId}
pubTypeId={props.pubTypeId}
pubTypes={props.pubTypes}
// @ts-expect-error - its fine, debounce returns `undefined` at the beginning
getPubs={debouncedGetPubs}
getPubById={() => {
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 (
<EditorLayout
{...sharedProps}
initialDisplay={props.initialDisplay}
initialPanes={props.initialPanes}
/>
)
}
return <ContextEditor {...sharedProps} />
}, [
props.pubTypes,
props.disabled,
Expand All @@ -106,6 +122,9 @@ export const ContextEditorClient = (
props.onChange,
props.pubId,
props.pubTypeId,
props.withLayout,
props.initialDisplay,
props.initialPanes,
signedUploadUrl,
])

Expand Down
3 changes: 2 additions & 1 deletion core/app/components/forms/elements/ContextEditorElement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
/>
</FormControl>
</div>
Expand Down
168 changes: 168 additions & 0 deletions development/features/myst-integration.md
Original file line number Diff line number Diff line change
@@ -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} <pub-id-or-slug>
: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."
1 change: 1 addition & 0 deletions packages/context-editor/.storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Preview } from "@storybook/react"

import "@pubpub/tailwind/style.css"
import "../src/tailwind.css"
import "../src/style.css"

Expand Down
1 change: 1 addition & 0 deletions packages/context-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
13 changes: 12 additions & 1 deletion packages/context-editor/src/ContextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -55,6 +56,14 @@ export interface ContextEditorProps {
hideMenu?: boolean
upload: (fileName: string) => Promise<string | { error: string }>

/**
* 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,
Expand Down Expand Up @@ -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(<MenuBar upload={props.upload} />, props.toolbarContainer)
) : (
<div className="sticky top-0 z-10">
<MenuBar upload={props.upload} />
</div>
Expand Down
Loading
Loading