Update toggle serializer

This commit is contained in:
Nate Kelley 2025-09-10 13:06:55 -06:00
parent 17e19adf4f
commit 744961bb10
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
1 changed files with 427 additions and 187 deletions

View File

@ -1,20 +1,46 @@
/**
* Toggle Serializer for PlateJS Markdown Kit
*
* This module handles the serialization and deserialization of toggle/collapsible content
* between PlateJS editor format and Markdown's <details>/<summary> HTML structure.
*
* Key Features:
* - Converts PlateJS toggle elements to HTML details/summary blocks
* - Handles proper nesting of content within toggles
* - Provides post-processing functions for complete toggle support
* - Maintains formatting integrity during conversion
*/
import { convertChildrenDeserialize, type MdNodeParser, serializeMd } from '@platejs/markdown';
import type { Descendant, TElement, Value } from 'platejs';
// =============================================================================
// TYPE DEFINITIONS
// =============================================================================
/**
* Represents a toggle node in the PlateJS editor structure
*/
interface ToggleNode extends TElement {
type: 'toggle';
id?: string;
children: Descendant[];
_tempContent?: MdastNode[]; // Temporary storage for content paragraphs
_tempContent?: MdastNode[]; // Temporary storage for nested content during processing
[key: string]: unknown;
}
/**
* Represents a node with indentation (used for nested toggle content)
*/
interface IndentedNode extends TElement {
indent?: number;
id?: string;
[key: string]: unknown;
}
/**
* Represents a Markdown AST node structure
*/
interface MdastNode {
type: string;
name?: string;
@ -23,88 +49,415 @@ interface MdastNode {
value?: string;
}
// =============================================================================
// CONSTANTS
// =============================================================================
/** Temporary marker used during serialization post-processing */
const TOGGLE_POST_PROCESSING_KEY = 'toggle-post-processing';
/** Regex pattern to match toggle post-processing markers */
const TOGGLE_POST_PROCESSING_MATCH = /<toggle-post-processing>(.*?)<\/toggle-post-processing>/;
/** Default indentation level for nested toggle content */
const DEFAULT_TOGGLE_INDENT = 1;
// =============================================================================
// MAIN SERIALIZER
// =============================================================================
/**
* Main toggle serializer that handles conversion between PlateJS and Markdown
*
* The serializer works in two phases:
* 1. Initial serialization creates temporary markers
* 2. Post-processing converts markers to proper HTML structure
*/
export const toggleSerializer: MdNodeParser<'toggle'> = {
serialize: (node, options) => {
validateSerializationOptions(options);
const titleContent = extractToggleTitle(node as ToggleNode, options);
// Create temporary marker for post-processing
return createTemporaryToggleMarker(titleContent);
},
deserialize: (node, _, options) => {
validateDeserializationOptions(options);
return deserializeToggleNode(node as MdastNode, options);
},
};
// =============================================================================
// SERIALIZATION HELPERS
// =============================================================================
/**
* Validates that required options are present for serialization
*/
function validateSerializationOptions(options: { editor?: unknown }): void {
if (!options.editor) {
throw new Error('Editor is required');
throw new Error('Editor is required for toggle serialization');
}
}
// Get the toggle title from the node's children (the summary text)
const titleContent = serializeMd(options.editor, {
/**
* Extracts the title content from a toggle node's children
*/
function extractToggleTitle(
node: ToggleNode,
options: { editor?: unknown; value?: unknown }
): string {
return serializeMd(options.editor as Parameters<typeof serializeMd>[0], {
...options,
value: node.children,
}).trim();
}
// Serialize to a special marker that we'll post-process
/**
* Creates a temporary HTML marker for post-processing
*/
function createTemporaryToggleMarker(titleContent: string): { type: string; value: string } {
return {
type: 'html',
value: `<${TOGGLE_POST_PROCESSING_KEY}>${titleContent}</${TOGGLE_POST_PROCESSING_KEY}>`,
};
},
deserialize: (node, _, options) => {
if (!options.editor) {
throw new Error('Editor is required');
}
// Parse details/summary structure
const mdastNode = node;
// =============================================================================
// DESERIALIZATION HELPERS
// =============================================================================
/**
* Validates that required options are present for deserialization
*/
function validateDeserializationOptions(options: { editor?: unknown }): void {
if (!options.editor) {
throw new Error('Editor is required for toggle deserialization');
}
}
/**
* Deserializes a Markdown AST node into a PlateJS toggle element
*/
function deserializeToggleNode(mdastNode: MdastNode, options: Record<string, unknown>): ToggleNode {
if (mdastNode.name === 'details') {
// Find summary content and collect other content
return processDetailsElement(mdastNode, options);
}
// Fallback for unrecognized node types
return createEmptyToggleElement();
}
/**
* Processes a details HTML element and extracts toggle structure
*/
function processDetailsElement(mdastNode: MdastNode, options: Record<string, unknown>): ToggleNode {
const { summaryChildren, contentParagraphs } = extractToggleComponents(mdastNode, options);
return createToggleElement(summaryChildren, contentParagraphs);
}
/**
* Extracts summary and content components from a details element
*/
function extractToggleComponents(
mdastNode: MdastNode,
options: Record<string, unknown>
): { summaryChildren: Descendant[]; contentParagraphs: MdastNode[] } {
let summaryChildren: Descendant[] = [];
const contentParagraphs: MdastNode[] = [];
if (mdastNode.children) {
if (!mdastNode.children) {
return { summaryChildren, contentParagraphs };
}
for (const child of mdastNode.children) {
// Check if this paragraph contains a summary element
if (child.type === 'paragraph' && child.children) {
const summaryElement = child.children?.find(
(grandchild: MdastNode) =>
grandchild.type === 'mdxJsxTextElement' && grandchild.name === 'summary'
);
const summaryElement = findSummaryElement(child);
if (summaryElement?.children) {
// Extract summary content preserving formatting
try {
summaryChildren = convertChildrenDeserialize(summaryElement.children, {}, options);
} catch (error) {
// Fallback to simple text extraction
summaryChildren = [{ text: extractTextFromNode(summaryElement) }];
}
summaryChildren = extractSummaryContent(summaryElement, options);
} else {
// This is content - store it for post-processing
// This is toggle content, not the summary
contentParagraphs.push(child);
}
}
}
return { summaryChildren, contentParagraphs };
}
// Create the toggle element with temporary content storage
const toggleElement: ToggleNode = {
/**
* Finds the summary element within a paragraph's children
*/
function findSummaryElement(paragraph: MdastNode): MdastNode | undefined {
return paragraph.children?.find(
(child: MdastNode) => child.type === 'mdxJsxTextElement' && child.name === 'summary'
);
}
/**
* Extracts and converts summary content to PlateJS format
*/
function extractSummaryContent(
summaryElement: MdastNode,
options: Record<string, unknown>
): Descendant[] {
try {
return convertChildrenDeserialize(
summaryElement.children as Parameters<typeof convertChildrenDeserialize>[0],
{},
options
);
} catch (error) {
// Fallback to simple text extraction if conversion fails
console.warn('Failed to convert summary content, falling back to text extraction:', error);
return [{ text: extractTextFromNode(summaryElement) }];
}
}
/**
* Creates a complete toggle element with summary and content
*/
function createToggleElement(
summaryChildren: Descendant[],
contentParagraphs: MdastNode[]
): ToggleNode {
return {
type: 'toggle',
children: summaryChildren.length > 0 ? summaryChildren : [{ text: '' }],
id: generateId(),
_tempContent: contentParagraphs, // Store content for post-processing
id: generateUniqueId(),
_tempContent: contentParagraphs,
};
console.log('DEBUG: Created toggle element:', toggleElement);
return toggleElement;
}
// Fallback for other node types
/**
* Creates an empty toggle element as a fallback
*/
function createEmptyToggleElement(): ToggleNode {
return {
type: 'toggle',
children: [{ text: '' }],
id: generateId(),
} as ToggleNode;
},
id: generateUniqueId(),
};
}
// Helper function to extract text content from a node
// =============================================================================
// POST-PROCESSING FUNCTIONS
// =============================================================================
/**
* Post-processes deserialized elements to properly handle toggle content
*
* This function converts temporary toggle storage into proper indented content
* that follows the toggle in the document structure.
*/
export function postProcessToggleDeserialization(elements: TElement[]): Value {
const result: Value = [];
for (const element of elements) {
const processedElements = processToggleElement(element);
result.push(...processedElements);
}
return result;
}
/**
* Processes a single element, handling toggle-specific logic
*/
function processToggleElement(element: TElement): TElement[] {
if (element.type === 'toggle') {
const toggleElement = element as ToggleNode;
if (toggleElement._tempContent) {
return processToggleWithContent(toggleElement);
}
}
return [element];
}
/**
* Processes a toggle element that has temporary content to be converted
*/
function processToggleWithContent(toggleElement: ToggleNode): TElement[] {
const contentElements = convertToggleContentToElements(toggleElement._tempContent || []);
const cleanToggleElement = createCleanToggleElement(toggleElement);
return [cleanToggleElement, ...contentElements];
}
/**
* Converts temporary toggle content to proper indented elements
*/
function convertToggleContentToElements(contentParagraphs: MdastNode[]): TElement[] {
const contentElements: TElement[] = [];
for (const contentParagraph of contentParagraphs) {
const element = convertParagraphToElement(contentParagraph);
if (element) {
contentElements.push(element);
}
}
return contentElements;
}
/**
* Converts a single paragraph to an indented PlateJS element
*/
function convertParagraphToElement(contentParagraph: MdastNode): TElement | null {
if (contentParagraph.type === 'paragraph' && contentParagraph.children) {
const paragraphText = extractTextFromNode(contentParagraph);
if (paragraphText.trim()) {
return {
type: 'p',
children: [{ text: paragraphText.trim() }],
indent: DEFAULT_TOGGLE_INDENT,
id: generateUniqueId(),
} as TElement;
}
}
return null;
}
/**
* Creates a clean toggle element without temporary content
*/
function createCleanToggleElement(toggleElement: ToggleNode): ToggleNode {
return {
type: 'toggle',
children: toggleElement.children,
id: toggleElement.id,
};
}
/**
* Post-processes markdown text to convert toggle markers to HTML details/summary
*
* This function handles the final conversion from temporary markers to proper
* HTML structure that can be rendered as collapsible content.
*/
export function postProcessToggleMarkdown(markdown: string): string {
const lines = markdown.split('\n');
const processedLines: string[] = [];
let lineIndex = 0;
while (lineIndex < lines.length) {
lineIndex = processMarkdownLine(lines, lineIndex, processedLines);
}
return processedLines.join('\n');
}
/**
* Processes a single line of markdown, handling toggle markers
*/
function processMarkdownLine(
lines: string[],
currentIndex: number,
processedLines: string[]
): number {
const line = lines[currentIndex];
const toggleMatch = line.match(TOGGLE_POST_PROCESSING_MATCH);
if (toggleMatch) {
return processToggleMarker(lines, currentIndex, toggleMatch[1], processedLines);
}
processedLines.push(line);
return currentIndex + 1;
}
/**
* Processes a toggle marker and converts it to HTML details/summary structure
*/
function processToggleMarker(
lines: string[],
startIndex: number,
title: string,
processedLines: string[]
): number {
// Add details opening tags
processedLines.push('<details>');
processedLines.push(`<summary>${title}</summary>`);
processedLines.push('');
const currentIndex = startIndex + 1;
const toggleContent = collectToggleContent(lines, currentIndex);
// Add collected content
processedLines.push(...toggleContent.content);
// Close details block
processedLines.push('</details>');
// Add spacing if there's more content
if (toggleContent.endIndex < lines.length) {
processedLines.push('');
}
return toggleContent.endIndex;
}
/**
* Collects content lines that belong to a toggle until a natural break point
*/
function collectToggleContent(
lines: string[],
startIndex: number
): { content: string[]; endIndex: number } {
const content: string[] = [];
let currentIndex = startIndex;
while (currentIndex < lines.length) {
const line = lines[currentIndex];
// Stop at another toggle marker
if (line.includes(`<${TOGGLE_POST_PROCESSING_KEY}>`)) {
break;
}
content.push(line);
currentIndex++;
// Stop at double empty lines (natural break point)
if (shouldStopAtDoubleEmptyLines(content, lines, currentIndex)) {
break;
}
}
return { content, endIndex: currentIndex };
}
/**
* Determines if we should stop collecting toggle content at double empty lines
*/
function shouldStopAtDoubleEmptyLines(
content: string[],
lines: string[],
currentIndex: number
): boolean {
return (
content.length > 1 &&
content[content.length - 1].trim() === '' &&
currentIndex < lines.length &&
lines[currentIndex]?.trim() === ''
);
}
// =============================================================================
// UTILITY FUNCTIONS
// =============================================================================
/**
* Recursively extracts text content from a Markdown AST node
*/
function extractTextFromNode(node: MdastNode): string {
if (node.type === 'text') {
return node.value || '';
@ -117,122 +470,9 @@ function extractTextFromNode(node: MdastNode): string {
return '';
}
// Simple ID generator
function generateId(): string {
return Math.random().toString(36).substr(2, 10);
}
// Post-processing function to handle details elements after deserialization
export function postProcessToggleDeserialization(elements: TElement[]): Value {
const result: Value = [];
for (const element of elements) {
// Check if this is a toggle element with temporary content
const toggleElement = element as ToggleNode;
if (toggleElement.type === 'toggle' && toggleElement._tempContent) {
// Convert the stored content paragraphs to indented elements
const contentElements: TElement[] = [];
for (const contentParagraph of toggleElement._tempContent) {
if (contentParagraph.type === 'paragraph' && contentParagraph.children) {
const paragraphText = extractTextFromNode(contentParagraph);
if (paragraphText.trim()) {
contentElements.push({
type: 'p',
children: [{ text: paragraphText.trim() }],
indent: 1,
id: generateId(),
} as TElement);
}
}
}
// Clean up the toggle element (remove temp content)
const cleanToggleElement: ToggleNode = {
type: 'toggle',
children: toggleElement.children,
id: toggleElement.id,
};
// Add toggle followed by indented content
result.push(cleanToggleElement);
result.push(...contentElements);
} else {
// Keep other elements as-is
result.push(element);
}
}
return result;
}
// Post-processing function to convert toggle markers to details/summary format
export function postProcessToggleMarkdown(markdown: string): string {
// This approach looks for toggle-post-processing markers followed by indented content
// and groups them into details/summary blocks
const lines = markdown.split('\n');
const processedLines: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Check if this line contains a toggle-post-processing marker
const toggleMatch = line.match(TOGGLE_POST_PROCESSING_MATCH);
if (toggleMatch) {
const title = toggleMatch[1];
// Start the details block
processedLines.push('<details>');
processedLines.push(`<summary>${title}</summary>`);
processedLines.push(''); // Empty line after summary
i++; // Move to next line
// Simple approach: collect everything until we hit another toggle or end of document
const toggleContent: string[] = [];
while (i < lines.length) {
const nextLine = lines[i];
// Stop if we hit another toggle marker
if (nextLine.includes(`<${TOGGLE_POST_PROCESSING_KEY}>`)) {
break;
}
// Add this line to toggle content
toggleContent.push(nextLine);
i++;
// Simple heuristic: if we hit two consecutive empty lines after some content, stop
if (
toggleContent.length > 1 &&
nextLine.trim() === '' &&
i < lines.length &&
lines[i]?.trim() === ''
) {
// Found two empty lines - this suggests end of toggle content
break;
}
}
// Add the collected content
processedLines.push(...toggleContent);
// Close the details block
processedLines.push('</details>');
// Add spacing after details if there's more content
if (i < lines.length) {
processedLines.push('');
}
} else {
processedLines.push(line);
i++;
}
}
return processedLines.join('\n');
/**
* Generates a unique identifier for PlateJS elements
*/
function generateUniqueId(): string {
return Math.random().toString(36).substring(2, 12);
}