From 86b2f3b438fd176ec8a3ed0e139c22fe75f580ba Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 9 Jul 2025 18:06:04 -0300 Subject: [PATCH 1/3] feat: implement Markdown support in SuperDoc editor - Added the `marked` library for Markdown parsing. - Enhanced the `Editor` class to handle Markdown content, converting it to HTML for rendering. - Updated `SuperDoc` and related components to accept and process Markdown input. - Introduced tests for Markdown conversion and integration to ensure functionality. - Added sample Markdown document for testing purposes. - Updated file upload component to accept Markdown files. --- package-lock.json | 15 +- packages/super-editor/package.json | 3 +- packages/super-editor/src/core/Editor.js | 137 ++++++-- .../src/tests/data/sample-markdown.md | 84 +++++ .../src/tests/import/markdownImporter.test.js | 213 +++++++++++ .../tests/integration/markdownToDocx.test.js | 331 ++++++++++++++++++ packages/superdoc/src/SuperDoc.vue | 168 ++++++--- .../superdoc/src/composables/use-document.js | 2 + packages/superdoc/src/core/SuperDoc.js | 4 +- .../src/dev/components/SuperdocDev.vue | 66 +++- .../superdoc/src/stores/superdoc-store.js | 3 +- shared/common/components/BasicUpload.vue | 2 +- 12 files changed, 929 insertions(+), 99 deletions(-) create mode 100644 packages/super-editor/src/tests/data/sample-markdown.md create mode 100644 packages/super-editor/src/tests/import/markdownImporter.test.js create mode 100644 packages/super-editor/src/tests/integration/markdownToDocx.test.js diff --git a/package-lock.json b/package-lock.json index 56bbb93be0..65b39628f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9359,6 +9359,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/marked": { + "version": "14.1.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.1.4.tgz", + "integrity": "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -16134,6 +16146,7 @@ "eventemitter3": "^5.0.1", "he": "^1.2.0", "jszip": "^3.10.1", + "marked": "^14.1.4", "prosemirror-commands": "^1.5.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", @@ -16494,7 +16507,7 @@ }, "packages/superdoc": { "name": "@harbour-enterprises/superdoc", - "version": "0.14.9-next.2", + "version": "0.14.9-next.8", "license": "AGPL-3.0", "dependencies": { "buffer-crc32": "^1.0.0", diff --git a/packages/super-editor/package.json b/packages/super-editor/package.json index 5d2d635b25..fbd65da56e 100644 --- a/packages/super-editor/package.json +++ b/packages/super-editor/package.json @@ -51,6 +51,7 @@ "eventemitter3": "^5.0.1", "he": "^1.2.0", "jszip": "^3.10.1", + "marked": "^14.1.4", "prosemirror-commands": "^1.5.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", @@ -91,4 +92,4 @@ "which": "^5.0.0", "@floating-ui/dom": "^1.7.0" } -} +} \ No newline at end of file diff --git a/packages/super-editor/src/core/Editor.js b/packages/super-editor/src/core/Editor.js index 80d854b135..606f1f9fca 100644 --- a/packages/super-editor/src/core/Editor.js +++ b/packages/super-editor/src/core/Editor.js @@ -1,6 +1,7 @@ import { EditorState } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import { DOMParser, DOMSerializer } from 'prosemirror-model'; +import { marked } from 'marked'; import { yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror'; import { helpers } from '@core/index.js'; import { EventEmitter } from './EventEmitter.js'; @@ -117,6 +118,7 @@ import { unflattenListsInHtml } from './inputRules/html/html-helpers.js'; * @property {boolean} [suppressDefaultDocxStyles] - Prevent default styles from being applied in docx mode * @property {boolean} [jsonOverride] - Whether to override content with provided json * @property {string} [html] - HTML content to initialize the editor with + * @property {string} [markdown] - Markdown content to initialize the editor with */ /** @@ -231,10 +233,10 @@ export class Editor extends EventEmitter { // telemetry telemetry: null, - + // Docx xml updated by User customUpdatedFiles: {}, - + isHeaderFooterChanged: false, isCustomXmlChanged: false, }; @@ -346,7 +348,7 @@ export class Editor extends EventEmitter { if (!this.options.ydoc) this.migrateListsToV2(); - this.setDocumentMode(this.options.documentMode); + this.setDocumentMode(this.options.documentMode); // Init pagination only if we are not in collaborative mode. Otherwise // it will be in itialized via this.#onCollaborationReady @@ -394,7 +396,7 @@ export class Editor extends EventEmitter { mount(el) { this.#createView(el); - + window.setTimeout(() => { if (this.isDestroyed) return; this.emit('create', { editor: this }); @@ -626,7 +628,7 @@ export class Editor extends EventEmitter { this.#createConverter(); this.#initMedia(); - + const doc = this.#generatePmData(); const tr = this.state.tr.replaceWith(0, this.state.doc.content.size, doc); tr.setMeta('replaceContent', true); @@ -920,8 +922,12 @@ export class Editor extends EventEmitter { // Perform any additional document processing prior to finalizing the doc here doc = this.#prepareDocumentForImport(doc); + // Check for markdown BEFORE html (since markdown gets converted to HTML) + if (this.options.markdown) { + doc = this.#createDocFromMarkdown(this.options.markdown); + } // If we have a new doc, and have html data, we initialize from html - if (this.options.html) doc = this.#createDocFromHTML(this.options.html) + else if (this.options.html) doc = this.#createDocFromHTML(this.options.html) else if (this.options.jsonOverride) doc = this.schema.nodeFromJSON(this.options.jsonOverride); if (fragment) doc = yXmlFragmentToProseMirrorRootNode(fragment, this.schema); @@ -960,6 +966,85 @@ export class Editor extends EventEmitter { return DOMParser.fromSchema(this.schema).parse(parsedContent); } + /** + * Create a document from Markdown content + * @private + * @param {string} content - Markdown content + * @returns {Object} Document node + */ + #createDocFromMarkdown(content) { + // First, convert markdown to HTML + const html = this.#convertMarkdownToHTML(content); + + // Then use existing HTML parser + return this.#createDocFromHTML(html); + } + + /** + * Convert Markdown to HTML with proper structure + * @private + * @param {string} markdown - Markdown content + * @returns {string} HTML content + */ + #convertMarkdownToHTML(markdown) { + // Configure marked for compatibility with your schema + marked.setOptions({ + breaks: true, // Convert \n to
+ gfm: true, // GitHub Flavored Markdown + headerIds: false, // Don't add IDs to headers + mangle: false, // Don't escape autolinks + }); + + // Convert markdown to HTML + let html = marked.parse(markdown); + + // Apply any necessary transformations for SuperDoc compatibility + html = this.#transformMarkdownHTML(html); + + return html; + } + + /** + * Transform markdown-generated HTML to be compatible with SuperDoc + * @private + * @param {string} html - HTML from markdown parser + * @returns {string} Transformed HTML + */ + #transformMarkdownHTML(html) { + // Create a temporary container + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + // Transform elements as needed for your schema + // Example: Convert

to

if needed + const headings = tempDiv.querySelectorAll('h1, h2, h3, h4, h5, h6'); + headings.forEach(heading => { + const level = parseInt(heading.tagName[1]); + heading.setAttribute('data-level', level); + }); + + // Transform lists to ensure compatibility + const lists = tempDiv.querySelectorAll('ul, ol'); + lists.forEach((list, index) => { + // Add any attributes your list schema expects + if (list.tagName === 'OL') { + list.setAttribute('data-list-id', index + 1); + } + }); + + // Transform code blocks + const codeBlocks = tempDiv.querySelectorAll('pre code'); + codeBlocks.forEach(code => { + // Add language class if specified + const parent = code.parentElement; + if (code.className) { + parent.setAttribute('data-language', code.className.replace('language-', '')); + } + }); + + return tempDiv.innerHTML; + } + /** * Create the PM editor view @@ -972,7 +1057,7 @@ export class Editor extends EventEmitter { // Only initialize the doc if we are not using Yjs/collaboration. const state = { schema: this.schema }; if (!this.options.ydoc) state.doc = doc; - + this.options.initialState = EditorState.create(state); this.view = new EditorView(element, { @@ -1021,7 +1106,7 @@ export class Editor extends EventEmitter { setWordSelection(view, pos); } }); - + const newState = this.state.reconfigure({ plugins: [...this.extensionService.plugins], }); @@ -1073,7 +1158,7 @@ export class Editor extends EventEmitter { if (!proseMirror || !element) { return; } - + proseMirror.setAttribute('role', 'document'); proseMirror.setAttribute('aria-multiline', true); proseMirror.setAttribute('aria-label', 'Main content area, start typing to enter text.'); @@ -1085,12 +1170,12 @@ export class Editor extends EventEmitter { element.style.minWidth = pageSize.width + 'in'; element.style.minHeight = pageSize.height + 'in'; } - + if (pageMargins) { element.style.paddingLeft = pageMargins.left + 'in'; element.style.paddingRight = pageMargins.right + 'in'; } - + element.style.boxSizing = 'border-box'; element.style.isolation = 'isolate'; // to create new stacking context. @@ -1289,10 +1374,10 @@ export class Editor extends EventEmitter { const tr = isTrackChangesActive ? trackedTransaction({ - tr: transaction, - state: this.state, - user: this.options.user, - }) + tr: transaction, + state: this.state, + user: this.options.user, + }) : transaction; const { state: newState } = this.view.state.applyTransaction(tr); @@ -1449,8 +1534,8 @@ export class Editor extends EventEmitter { return setImageNodeSelection(view, pos); } } - - + + /** * Perform any post conversion pre prosemirror import processing. * Comments are processed here. @@ -1498,7 +1583,7 @@ export class Editor extends EventEmitter { const updatedState = newState.apply(tr); return updatedState.doc.toJSON(); } - + getUpdatedJson() { return this.#prepareDocumentForExport(); } @@ -1544,7 +1629,7 @@ export class Editor extends EventEmitter { const customSettings = this.converter.schemaToXml(this.converter.convertedXml['word/settings.xml'].elements[0]); const rels = this.converter.schemaToXml(this.converter.convertedXml['word/_rels/document.xml.rels'].elements[0]); const media = this.converter.addedMedia; - + const updatedHeadersFooters = {}; Object.entries(this.converter.convertedXml).forEach(([name, json]) => { if (name.includes('header') || name.includes('footer')) { @@ -1581,14 +1666,14 @@ export class Editor extends EventEmitter { } const zipper = new DocxZipper(); - + if (getUpdatedDocs) { updatedDocs['[Content_Types].xml'] = await zipper.updateContentTypes({ files: this.options.content }, media, true); return updatedDocs; } - + const result = await zipper.updateZip({ docx: this.options.content, updatedDocs: updatedDocs, @@ -1617,7 +1702,7 @@ export class Editor extends EventEmitter { console.debug('🔗 [super-editor] Ending collaboration'); if (this.options.collaborationProvider) this.options.collaborationProvider.disconnect(); if (this.options.ydoc) this.options.ydoc.destroy(); - } catch (error) {} + } catch (error) { } } /** @@ -1637,7 +1722,7 @@ export class Editor extends EventEmitter { destroyHeaderFooterEditors() { try { const editors = [ - ...this.converter.headerEditors, + ...this.converter.headerEditors, ...this.converter.footerEditors, ]; for (let editorData of editors) { @@ -1645,7 +1730,7 @@ export class Editor extends EventEmitter { } this.converter.headerEditors.length = 0; this.converter.footerEditors.length = 0; - } catch (error) {} + } catch (error) { } } /** @@ -1743,13 +1828,13 @@ export class Editor extends EventEmitter { console.warn('Cannot find file in docx') return null; } - + if (type === 'json') { return this.converter.convertedXml[name].elements[0] || null; } return this.converter.schemaToXml(this.converter.convertedXml[name].elements[0]); } - + /** * Update internal docx file content * @param {string} name - File name diff --git a/packages/super-editor/src/tests/data/sample-markdown.md b/packages/super-editor/src/tests/data/sample-markdown.md new file mode 100644 index 0000000000..0520eb2933 --- /dev/null +++ b/packages/super-editor/src/tests/data/sample-markdown.md @@ -0,0 +1,84 @@ +# Sample Markdown Document + +This is a sample markdown document used for testing SuperDoc's markdown import functionality. + +## Features Demonstrated + +### Text Formatting + +This document contains **bold text**, _italic text_, and `inline code` examples. + +### Lists + +#### Ordered List + +1. First item +2. Second item with **bold** text +3. Third item with _italic_ text + +#### Unordered List + +- Bullet point one +- Bullet point two with `code` +- Bullet point three + +### Code Blocks + +```javascript +// JavaScript example +function greetUser(name) { + console.log(`Hello, ${name}!`); + return `Welcome to SuperDoc`; +} + +greetUser('Developer'); +``` + +```python +# Python example +def calculate_sum(a, b): + """Calculate the sum of two numbers.""" + return a + b + +result = calculate_sum(5, 3) +print(f"The sum is: {result}") +``` + +### Links and References + +Visit [SuperDoc Documentation](https://superdoc.com/docs) for comprehensive guides. + +For support, contact us at [support@superdoc.com](mailto:support@superdoc.com). + +### Blockquotes + +> "The best way to predict the future is to create it." +> +> This blockquote demonstrates how markdown blockquotes are converted to proper document formatting. + +### Tables + +| Feature | Status | Priority | +| ----------- | ----------- | -------- | +| Headers | ✅ Complete | High | +| Lists | ✅ Complete | High | +| Code Blocks | ✅ Complete | Medium | +| Tables | 🔄 Testing | Medium | +| Images | ⏳ Planned | Low | + +### Mixed Content Example + +Here's a paragraph that combines **bold text**, _italic text_, and `inline code` with a [link to documentation](https://example.com). + +```bash +# Terminal commands +npm install @harbour-enterprises/superdoc +npm run build +npm test +``` + +> **Note:** This sample document covers most common markdown features to ensure comprehensive testing of the conversion pipeline. + +## Conclusion + +This markdown document should convert cleanly to both HTML and DOCX formats through SuperDoc's conversion pipeline, preserving all formatting and structure. diff --git a/packages/super-editor/src/tests/import/markdownImporter.test.js b/packages/super-editor/src/tests/import/markdownImporter.test.js new file mode 100644 index 0000000000..865bb4ee80 --- /dev/null +++ b/packages/super-editor/src/tests/import/markdownImporter.test.js @@ -0,0 +1,213 @@ +import { marked } from 'marked'; + +describe('MarkdownImporter', () => { + describe('Markdown to HTML Conversion', () => { + it('converts basic markdown to HTML using marked library', () => { + const markdown = '# Heading\n\nThis is **bold** and *italic* text.'; + + // Configure marked + marked.setOptions({ + breaks: true, + gfm: true, + headerIds: false, + mangle: false, + }); + + const result = marked.parse(markdown); + + expect(result).toContain('

Heading

'); + expect(result).toContain('bold'); + expect(result).toContain('italic'); + }); + + it('handles code blocks correctly', () => { + const markdown = '```javascript\nconsole.log("Hello World");\n```'; + + marked.setOptions({ + breaks: true, + gfm: true, + headerIds: false, + mangle: false, + }); + + const result = marked.parse(markdown); + + expect(result).toContain('
');
+      expect(result).toContain(' {
+      const markdown = '1. First item\n2. Second item\n\n- Bullet one\n- Bullet two';
+
+      marked.setOptions({
+        breaks: true,
+        gfm: true,
+        headerIds: false,
+        mangle: false,
+      });
+
+      const result = marked.parse(markdown);
+
+      expect(result).toContain('
    '); + expect(result).toContain('
      '); + expect(result).toContain('
    • First item
    • '); + expect(result).toContain('
    • Bullet one
    • '); + }); + + it('handles links correctly', () => { + const markdown = '[SuperDoc](https://example.com) is great!'; + + marked.setOptions({ + breaks: true, + gfm: true, + headerIds: false, + mangle: false, + }); + + const result = marked.parse(markdown); + + expect(result).toContain('SuperDoc'); + }); + + it('handles blockquotes', () => { + const markdown = '> This is a blockquote\n> with multiple lines'; + + marked.setOptions({ + breaks: true, + gfm: true, + headerIds: false, + mangle: false, + }); + + const result = marked.parse(markdown); + + expect(result).toContain('
      '); + expect(result).toContain('This is a blockquote'); + }); + + it('handles tables with GitHub Flavored Markdown', () => { + const markdown = `| Header 1 | Header 2 | +|----------|----------| +| Cell 1 | Cell 2 | +| Cell 3 | Cell 4 |`; + + marked.setOptions({ + breaks: true, + gfm: true, + headerIds: false, + mangle: false, + }); + + const result = marked.parse(markdown); + + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain(''); + }); + }); + + describe('HTML Transformation for SuperDoc Compatibility', () => { + it('transforms headings to include data-level attributes', () => { + const html = '

      Heading 1

      Heading 2

      Heading 3

      '; + + // Simulate the transformation logic + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + const headings = tempDiv.querySelectorAll('h1, h2, h3, h4, h5, h6'); + headings.forEach(heading => { + const level = parseInt(heading.tagName[1]); + heading.setAttribute('data-level', level); + }); + + const result = tempDiv.innerHTML; + + expect(result).toContain('data-level="1"'); + expect(result).toContain('data-level="2"'); + expect(result).toContain('data-level="3"'); + }); + + it('processes code blocks to add language attributes', () => { + const html = '
      console.log("test");
      '; + + // Simulate the transformation logic + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + const codeBlocks = tempDiv.querySelectorAll('pre code'); + codeBlocks.forEach(code => { + const parent = code.parentElement; + if (code.className) { + parent.setAttribute('data-language', code.className.replace('language-', '')); + } + }); + + const result = tempDiv.innerHTML; + + expect(result).toContain('data-language="javascript"'); + }); + + it('adds list IDs to ordered lists', () => { + const html = '
      1. Item 1
      1. Item 2
      '; + + // Simulate the transformation logic + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + const lists = tempDiv.querySelectorAll('ol'); + lists.forEach((list, index) => { + list.setAttribute('data-list-id', index + 1); + }); + + const result = tempDiv.innerHTML; + + expect(result).toContain('data-list-id="1"'); + expect(result).toContain('data-list-id="2"'); + }); + }); + + describe('Markdown Configuration', () => { + it('configures marked with SuperDoc-compatible options', () => { + // Test the configuration options + const options = { + breaks: true, // Convert \n to
      + gfm: true, // GitHub Flavored Markdown + headerIds: false, // Don't add IDs to headers + mangle: false, // Don't escape autolinks + }; + + marked.setOptions(options); + + // Test line breaks + const lineBreakTest = 'Line 1\nLine 2'; + const lineBreakResult = marked.parse(lineBreakTest); + expect(lineBreakResult).toContain('
      '); + + // Test that header IDs are not added + const headerTest = '# Test Header'; + const headerResult = marked.parse(headerTest); + expect(headerResult).not.toContain('id='); + expect(headerResult).toContain('

      Test Header

      '); + }); + + it('handles edge cases in markdown parsing', () => { + marked.setOptions({ + breaks: true, + gfm: true, + headerIds: false, + mangle: false, + }); + + // Empty string + expect(marked.parse('')).toBe(''); + + // Just whitespace should return empty string (which is correct) + expect(marked.parse(' \n\n ')).toBe(''); + + // Malformed markdown should not crash + const malformed = '**Bold without closing\n[Link without closing(url\n```code without closing'; + expect(() => marked.parse(malformed)).not.toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/packages/super-editor/src/tests/integration/markdownToDocx.test.js b/packages/super-editor/src/tests/integration/markdownToDocx.test.js new file mode 100644 index 0000000000..9fdd86a55b --- /dev/null +++ b/packages/super-editor/src/tests/integration/markdownToDocx.test.js @@ -0,0 +1,331 @@ +import { marked } from 'marked'; + +describe('Markdown Integration Tests', () => { + describe('Markdown Processing Pipeline', () => { + it('processes a comprehensive markdown document through the conversion pipeline', () => { + const markdown = `# SuperDoc Markdown Test + +This document tests the **complete** conversion pipeline from *Markdown* to HTML. + +## Features Tested + +### Text Formatting +- **Bold text** +- *Italic text* +- \`inline code\` + +### Lists + +#### Ordered Lists +1. First item +2. Second item + 1. Nested item + 2. Another nested item +3. Third item + +#### Unordered Lists +- Bullet point one +- Bullet point two + - Nested bullet + - Another nested bullet +- Bullet point three + +### Links and Code + +Visit [SuperDoc](https://superdoc.com) for more information. + +\`\`\`javascript +// This is a code block +function convertMarkdown() { + console.log("Converting markdown to DOCX"); + return "success"; +} +\`\`\` + +\`\`\`python +# Python example +def hello_world(): + print("Hello from SuperDoc!") +\`\`\` + +### Blockquotes + +> This is a blockquote that demonstrates +> how markdown blockquotes are converted +> to proper DOCX formatting. + +### Tables + +| Feature | Status | Notes | +|---------|---------|-------| +| Headers | ✅ | Working | +| Lists | ✅ | Working | +| Code | ✅ | Working | +| Tables | 🔄 | Testing | + +### Final Thoughts + +This comprehensive test ensures that all major markdown features are properly converted to HTML format through SuperDoc's conversion pipeline. +`; + + // Configure marked for SuperDoc compatibility + marked.setOptions({ + breaks: true, + gfm: true, + headerIds: false, + mangle: false, + }); + + // Convert markdown to HTML + const html = marked.parse(markdown); + + // Verify the conversion worked + expect(html).toBeDefined(); + expect(typeof html).toBe('string'); + expect(html.length).toBeGreaterThan(0); + + // Test major elements are present + expect(html).toContain('

      SuperDoc Markdown Test

      '); + expect(html).toContain('

      Features Tested

      '); + expect(html).toContain('Bold text'); + expect(html).toContain('Italic text'); + expect(html).toContain('inline code'); + expect(html).toContain('
        '); + expect(html).toContain('
          '); + expect(html).toContain('
          '); + expect(html).toContain('
      Header 1Cell 1
      '); + expect(html).toContain('
      ');
      +      expect(html).toContain('href="https://superdoc.com"');
      +
      +      // Apply HTML transformations for SuperDoc compatibility
      +      const tempDiv = document.createElement('div');
      +      tempDiv.innerHTML = html;
      +
      +      // Transform headings
      +      const headings = tempDiv.querySelectorAll('h1, h2, h3, h4, h5, h6');
      +      headings.forEach(heading => {
      +        const level = parseInt(heading.tagName[1]);
      +        heading.setAttribute('data-level', level);
      +      });
      +
      +      // Transform code blocks
      +      const codeBlocks = tempDiv.querySelectorAll('pre code');
      +      codeBlocks.forEach(code => {
      +        const parent = code.parentElement;
      +        if (code.className) {
      +          parent.setAttribute('data-language', code.className.replace('language-', ''));
      +        }
      +      });
      +
      +      // Transform ordered lists
      +      const lists = tempDiv.querySelectorAll('ol');
      +      lists.forEach((list, index) => {
      +        list.setAttribute('data-list-id', index + 1);
      +      });
      +
      +      const transformedHTML = tempDiv.innerHTML;
      +
      +      // Verify transformations were applied
      +      expect(transformedHTML).toContain('data-level="1"');
      +      expect(transformedHTML).toContain('data-level="2"');
      +      expect(transformedHTML).toContain('data-language="javascript"');
      +      expect(transformedHTML).toContain('data-language="python"');
      +      expect(transformedHTML).toContain('data-list-id="1"');
      +    });
      +
      +    it('handles complex nested structures', () => {
      +      const markdown = `# Complex Document
      +
      +## Section with Mixed Content
      +
      +This section contains **bold text** and *italic text* mixed with \`inline code\`.
      +
      +### Nested Lists with Code
      +
      +1. **Setup Instructions**
      +   - Install dependencies: \`npm install\`
      +   - Run the application: \`npm start\`
      +   
      +2. **Development Workflow**
      +   \`\`\`bash
      +   git checkout -b feature/new-feature
      +   git add .
      +   git commit -m "Add new feature"
      +   \`\`\`
      +   
      +3. **Deployment**
      +   > Remember to test thoroughly before deploying
      +   > to production environment.
      +
      +### Code Examples in Lists
      +
      +- **Frontend Code**:
      +  \`\`\`javascript
      +  import React from 'react';
      +  
      +  function App() {
      +    return 
      Hello World
      ; + } + \`\`\` + +- **Backend Code**: + \`\`\`python + from flask import Flask + + app = Flask(__name__) + + @app.route('/') + def hello(): + return "Hello World!" + \`\`\` + +### Links in Various Contexts + +Visit [our documentation](https://docs.example.com) for more details, or check out our [GitHub repository](https://github.com/example/repo). + +> For support, please contact us at [support@example.com](mailto:support@example.com). +`; + + marked.setOptions({ + breaks: true, + gfm: true, + headerIds: false, + mangle: false, + }); + + const html = marked.parse(markdown); + + // Verify complex structures are handled + expect(html).toContain('

      Complex Document

      '); + expect(html).toContain('
        '); + expect(html).toContain('
          '); + expect(html).toContain('
          '); + expect(html).toContain('language-bash'); + expect(html).toContain('language-javascript'); + expect(html).toContain('language-python'); + expect(html).toContain('href="https://docs.example.com"'); + expect(html).toContain('href="mailto:support@example.com"'); + + // Verify nested content is preserved + expect(html).toContain('Install dependencies'); + expect(html).toContain('git checkout'); + expect(html).toContain('import React'); + expect(html).toContain('from flask'); + }); + + it('handles edge cases gracefully', () => { + const edgeCases = [ + '', // Empty string + ' \n\n ', // Whitespace only + '# Single heading', // Single element + '**Bold without closing', // Malformed formatting + '[Link without closing(url', // Malformed link + '```\ncode without language\n```', // Code without language + '> Single line blockquote', // Simple blockquote + '| Single | Column |\n|--------|--------|', // Simple table + ]; + + marked.setOptions({ + breaks: true, + gfm: true, + headerIds: false, + mangle: false, + }); + + edgeCases.forEach((testCase, index) => { + expect(() => { + const result = marked.parse(testCase); + expect(typeof result).toBe('string'); + }).not.toThrow(`Edge case ${index + 1} should not throw`); + }); + }); + + it('maintains performance with large documents', () => { + // Create a large markdown document + const baseSection = `## Section + +This is content with **bold** and *italic* text. + +- List item +- Another item + +\`\`\`javascript +console.log("code"); +\`\`\` + +> Blockquote content +`; + + const largeMarkdown = Array(100).fill(baseSection).join('\n\n'); + + marked.setOptions({ + breaks: true, + gfm: true, + headerIds: false, + mangle: false, + }); + + const startTime = performance.now(); + const result = marked.parse(largeMarkdown); + const endTime = performance.now(); + + const processingTime = endTime - startTime; + + // Should process large documents reasonably quickly + expect(processingTime).toBeLessThan(1000); // 1 second + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + expect(result).toContain('

          Section

          '); + }); + + it('produces valid HTML output', () => { + const markdown = `# Test Document + +This is a **test** with various elements: + +1. Ordered list +2. With multiple items + +- Bullet list +- Also with items + +\`\`\`javascript +console.log("code block"); +\`\`\` + +> Blockquote text + +[Link](https://example.com) + +| Table | Header | +|-------|--------| +| Cell | Data | +`; + + marked.setOptions({ + breaks: true, + gfm: true, + headerIds: false, + mangle: false, + }); + + const html = marked.parse(markdown); + + // Create a temporary element to test if HTML is valid + const tempDiv = document.createElement('div'); + expect(() => { + tempDiv.innerHTML = html; + }).not.toThrow(); + + // Basic structure validation + expect(tempDiv.querySelector('h1')).toBeTruthy(); + expect(tempDiv.querySelector('strong')).toBeTruthy(); + expect(tempDiv.querySelector('ol')).toBeTruthy(); + expect(tempDiv.querySelector('ul')).toBeTruthy(); + expect(tempDiv.querySelector('pre')).toBeTruthy(); + expect(tempDiv.querySelector('blockquote')).toBeTruthy(); + expect(tempDiv.querySelector('a')).toBeTruthy(); + expect(tempDiv.querySelector('table')).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index e569515b1a..cb44c6b11f 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -94,9 +94,9 @@ const { initAiLayer, showAiWriterAtCursor, handleAiWriterClose, - handleAiToolClick + handleAiToolClick, } = useAi({ - activeEditorRef + activeEditorRef, }); // Hrbr Fields @@ -146,7 +146,7 @@ const onCommentsLoaded = ({ editor, comments, replacedFile }) => { superdoc: proxy.$superdoc, editor, comments, - documentId: editor.options.documentId + documentId: editor.options.documentId, }); }); } @@ -178,9 +178,9 @@ const onEditorDocumentLocked = ({ editor, isLocked, lockedBy }) => { proxy.$superdoc.lockSuperdoc(isLocked, lockedBy); }; -const onEditorUpdate = ({editor}) => { - proxy.$superdoc.emit('editor-update', { editor }) -} +const onEditorUpdate = ({ editor }) => { + proxy.$superdoc.emit('editor-update', { editor }); +}; const onEditorSelectionChange = ({ editor, transaction }) => { if (skipSelectionUpdate.value) { @@ -266,6 +266,7 @@ const editorOptions = (doc) => { colors: proxy.$superdoc.colors, role: proxy.$superdoc.config.role, html: doc.html, + markdown: doc.markdown, documentMode: proxy.$superdoc.config.documentMode, rulers: doc.rulers, isInternal: proxy.$superdoc.config.isInternal, @@ -303,7 +304,7 @@ const editorOptions = (doc) => { /** * Trigger a comment-positions location update * This is called when the editor has updated the comment locations - * + * * @returns {void} */ const onEditorCommentLocationsUpdate = ({ allCommentIds: activeThreadId, allCommentPositions }) => { @@ -318,7 +319,7 @@ const onEditorCommentsUpdate = (params = {}) => { if (type === 'trackedChange') { handleTrackedChangeUpdate({ superdoc: proxy.$superdoc, params }); } - + nextTick(() => { if (pendingComment.value) return; commentsStore.setActiveComment(activeCommentId); @@ -334,13 +335,11 @@ const isCommentsEnabled = computed(() => 'comments' in modules); const showCommentsSidebar = computed(() => { return ( pendingComment.value || - ( - getFloatingComments.value?.length > 0 - && isReady.value - && layers.value - && isCommentsEnabled.value - && !isCommentsListVisible.value - ) + (getFloatingComments.value?.length > 0 && + isReady.value && + layers.value && + isCommentsEnabled.value && + !isCommentsListVisible.value) ); }); @@ -359,7 +358,7 @@ watch(showCommentsSidebar, (value) => { /** * Scroll the page to a given commentId - * + * * @param {String} commentId The commentId to scroll to */ const scrollToComment = (commentId) => { @@ -367,7 +366,7 @@ const scrollToComment = (commentId) => { const element = document.querySelector(`[data-thread-id=${commentId}]`); if (element) { - element.scrollIntoView({ behavior: "smooth", block: "start" }); + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); commentsStore.setActiveComment(commentId); } }; @@ -409,7 +408,7 @@ const handleSelectionChange = (selection) => { if (!selection.selectionBounds || !isCommentsEnabled.value) return; resetSelection(); - + const isMobileView = window.matchMedia('(max-width: 768px)').matches; updateSelection({ @@ -488,8 +487,8 @@ const handleSelectionStart = (e) => { nextTick(() => { isDragging.value = true; - const y = e.offsetY / (activeZoom.value / 100) - const x = e.offsetX / (activeZoom.value / 100) + const y = e.offsetY / (activeZoom.value / 100); + const x = e.offsetX / (activeZoom.value / 100); updateSelection({ startX: x, startY: y }); selectionLayer.value.addEventListener('mousemove', handleDragMove); }); @@ -497,8 +496,8 @@ const handleSelectionStart = (e) => { const handleDragMove = (e) => { if (!isDragging.value) return; - const y = e.offsetY / (activeZoom.value / 100) - const x = e.offsetX / (activeZoom.value / 100) + const y = e.offsetY / (activeZoom.value / 100); + const x = e.offsetX / (activeZoom.value / 100); updateSelection({ x, y }); }; @@ -543,78 +542,131 @@ watch(getFloatingComments, () => { hasInitializedLocations.value = true; }); }); - -