composite-merge.ts9.2 KBView on GitHub /**
* Composite playbook merge / split — pure ProseMirror-JSON transforms.
*
* The composite editor shows ONE document built from two underlying playbooks
* (user + org). `mergeComposite` interleaves them per section; `splitComposite`
* is the inverse, used on save to write each scope back to its own row.
*
* Shape of an underlying playbook doc (from parse-playbook-xml.ts): the top-level
* children are section nodes —
* - `alwaysLoadedSection` → key `always-loaded`
* - `onDemandSection` → key `on-demand`
* - `globalSection` → key `global`
* - `stageSection {id,label}` → key `stage:{id}`
*
* Shape of the composite doc: the top-level children are `compositeSection`
* groups (one per section key), each holding an org and/or user `scopeSection`.
*
* No I/O, no React, no TipTap runtime — safe to unit-test in isolation.
*/
/** Minimal ProseMirror-JSON node (a structural subset of TipTap's JSONContent). */
export interface PMNode {
type: string;
attrs?: Record<string, unknown>;
content?: PMNode[];
text?: string;
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;
}
export const COMPOSITE_SECTION = 'compositeSection';
export const SCOPE_SECTION = 'scopeSection';
export type Scope = 'org' | 'user';
/** Section key for a top-level playbook section node, or null if not a section. */
function sectionKeyOf(node: PMNode): string | null {
switch (node.type) {
case 'alwaysLoadedSection':
return 'always-loaded';
case 'onDemandSection':
return 'on-demand';
case 'globalSection':
return 'global';
case 'stageSection':
return `stage:${String(node.attrs?.id ?? '')}`;
default:
return null;
}
}
/** Map of section key → section node, preserving document order. */
function topSections(doc: PMNode | null | undefined): Map<string, PMNode> {
const map = new Map<string, PMNode>();
for (const node of doc?.content ?? []) {
const key=[redacted];
if (key && !map.has(key)) map.set(key, node);
}
return map;
}
function nonEmpty(content: PMNode[]): PMNode[] {
return content.length > 0 ? content : [{ type: 'paragraph' }];
}
/** True for the structural entry/exit criteria nodes hidden on user stage cards. */
function isStageCriteria(n: PMNode): boolean {
return n.type === 'stageEntry' || n.type === 'stageExit';
}
/**
* Recursively drop decorative `horizontalRule` nodes (markdown `---`). They have
* no meaning in a playbook and otherwise render as a stray divider under a card's
* badge (e.g. a cross-cutting block that is just `---`). A cross-cutting wrapper
* left empty by the strip is dropped so the card reads as empty, not blank-ruled.
*/
function stripDecorativeRules(nodes: PMNode[]): PMNode[] {
const out: PMNode[] = [];
for (const n of nodes) {
if (n.type === 'horizontalRule') continue;
if (n.content) {
const inner = stripDecorativeRules(n.content);
if (n.type === 'crossCuttingSection' && inner.length === 0) continue;
out.push({ ...n, content: inner });
} else {
out.push(n);
}
}
return out;
}
/**
* Build a scope sub-card. User stage cards HIDE entry/exit criteria (they are
* not deleted — `splitComposite` restores them from the original user doc).
*/
function makeScope(scope: Scope, inner: PMNode[], hideStageCriteria: boolean): PMNode {
let content = stripDecorativeRules(inner);
if (hideStageCriteria) content = content.filter((n) => !isStageCriteria(n));
return { type: SCOPE_SECTION, attrs: { scope }, content: nonEmpty(content) };
}
/** Map of stageId → its entry/exit nodes, in document order, from a playbook doc. */
function stageCriteriaByStage(doc: PMNode | null | undefined): Map<string, PMNode[]> {
const map = new Map<string, PMNode[]>();
for (const node of doc?.content ?? []) {
if (node.type !== 'stageSection') continue;
const criteria = (node.content ?? []).filter(isStageCriteria);
if (criteria.length) map.set(String(node.attrs?.id ?? ''), criteria);
}
return map;
}
/**
* Recursively drop empty text nodes (`{type:'text', text:''}`). ProseMirror's
* schema forbids them — `setContent` throws `RangeError: Empty text nodes are
* not allowed` and the whole editor renders blank. They creep in from parsing
* empty markdown leaves (e.g. an empty ``` code fence in a cross-cutting block
* becomes a codeBlock holding one empty text node), so sanitize before the doc
* ever reaches the editor.
*/
export function stripEmptyTextNodes(node: PMNode): PMNode {
if (!node.content) return node;
const content: PMNode[] = [];
for (const child of node.content) {
if (child.type === 'text' && !(typeof child.text === 'string' && child.text.length > 0)) continue;
content.push(stripEmptyTextNodes(child));
}
return { ...node, content };
}
/**
* Merge the user + org playbook docs into one composite doc. Sections appear in
* canonical order (always-loaded, on-demand, global, then stages — org stages
* first, then any user-only stages). Each section group contains an org card
* and/or a user card; the user stage card has Entry/Exit criteria removed.
*/
export function mergeComposite(userJson: PMNode | null, orgJson: PMNode | null): PMNode {
const userSections = topSections(userJson);
const orgSections = topSections(orgJson);
const order: string[] = [];
const pushKey = (k: string) => {
if ((orgSections.has(k) || userSections.has(k)) && !order.includes(k)) order.push(k);
};
(['always-loaded', 'on-demand', 'global'] as const).forEach(pushKey);
for (const k of orgSections.keys()) if (k.startsWith('stage:')) pushKey(k);
for (const k of userSections.keys()) if (k.startsWith('stage:')) pushKey(k);
const children: PMNode[] = [];
for (const key of order) {
const orgNode = orgSections.get(key);
const userNode = userSections.get(key);
const isStage = key.startsWith('stage:');
const ref = orgNode ?? userNode;
const attrs: Record<string, unknown> = { sectionKey=[redacted] ? 'stage' : key };
if (isStage) {
attrs.stageId = String(ref?.attrs?.id ?? key.slice('stage:'.length));
attrs.label = String(ref?.attrs?.label ?? '');
}
const scopes: PMNode[] = [];
if (orgNode) scopes.push(makeScope('org', orgNode.content ?? [], false));
if (userNode) scopes.push(makeScope('user', userNode.content ?? [], isStage));
// Mark every scope after the first so it can draw the divider above it (a
// CSS `divide-y` can't be used — TipTap wraps node-view children in an
// extra div, so adjacent-sibling selectors never match).
scopes.forEach((s, i) => {
s.attrs = { ...s.attrs, divider: i > 0 };
});
children.push({ type: COMPOSITE_SECTION, attrs, content: scopes });
}
// Sanitize away empty text nodes so `setContent` never rejects the doc.
return stripEmptyTextNodes({ type: 'doc', content: nonEmpty(children) });
}
/** Rebuild an underlying section node from a composite section's attrs + scope content. */
function rebuildSection(
sectionKey=[redacted],
stageId: string,
label: string,
content: PMNode[],
): PMNode {
switch (sectionKey) {
case 'always-loaded':
return { type: 'alwaysLoadedSection', content: nonEmpty(content) };
case 'on-demand':
return { type: 'onDemandSection', content: nonEmpty(content) };
case 'stage':
return { type: 'stageSection', attrs: { id: stageId, label }, content: nonEmpty(content) };
case 'global':
default:
return { type: 'globalSection', content: nonEmpty(content) };
}
}
/**
* Split a composite doc back into separate user + org playbook docs. Inverse of
* `mergeComposite`. Entry/exit criteria hidden from user stage cards are NOT
* lost: pass the original user doc as `originalUserJson` and they are restored
* to the front of each user stage, so the user playbook round-trips faithfully.
*/
export function splitComposite(
composite: PMNode,
originalUserJson?: PMNode | null,
): { userJson: PMNode; orgJson: PMNode } {
const userContent: PMNode[] = [];
const orgContent: PMNode[] = [];
const preservedCriteria = stageCriteriaByStage(originalUserJson);
for (const group of composite.content ?? []) {
if (group.type !== COMPOSITE_SECTION) continue;
const sectionKey=[redacted] ?? 'global');
const stageId = String(group.attrs?.stageId ?? '');
const label = String(group.attrs?.label ?? '');
for (const scope of group.content ?? []) {
if (scope.type !== SCOPE_SECTION) continue;
const isUser = scope.attrs?.scope !== 'org';
let content = scope.content ?? [];
// Restore the user stage's hidden entry/exit criteria (front of the stage,
// matching the parser's order) so they are preserved, not deleted.
if (isUser && sectionKey === 'stage') {
const preserved = preservedCriteria.get(stageId);
if (preserved?.length) {
const body = content.filter((n) => n.type !== 'paragraph' || (n.content?.length ?? 0) > 0);
content = [...preserved, ...body];
}
}
const section = rebuildSection(sectionKey, stageId, label, content);
if (isUser) userContent.push(section);
else orgContent.push(section);
}
}
return {
userJson: { type: 'doc', content: nonEmpty(userContent) },
orgJson: { type: 'doc', content: nonEmpty(orgContent) },
};
}