mirror of https://github.com/buster-so/buster.git
Update toggle serializer
This commit is contained in:
parent
17e19adf4f
commit
744961bb10
|
@ -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) => {
|
||||
if (!options.editor) {
|
||||
throw new Error('Editor is required');
|
||||
}
|
||||
validateSerializationOptions(options);
|
||||
|
||||
// Get the toggle title from the node's children (the summary text)
|
||||
const titleContent = serializeMd(options.editor, {
|
||||
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 for toggle serialization');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
// =============================================================================
|
||||
// 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');
|
||||
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') {
|
||||
return processDetailsElement(mdastNode, options);
|
||||
}
|
||||
|
||||
// Parse details/summary structure
|
||||
const mdastNode = node;
|
||||
// Fallback for unrecognized node types
|
||||
return createEmptyToggleElement();
|
||||
}
|
||||
|
||||
if (mdastNode.name === 'details') {
|
||||
// Find summary content and collect other content
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the toggle element with temporary content storage
|
||||
const toggleElement: ToggleNode = {
|
||||
return { summaryChildren, contentParagraphs };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue