Skip to content

Commit c21c489

Browse files
committed
Add markdown importer
1 parent 12f589c commit c21c489

File tree

1 file changed

+205
-0
lines changed

1 file changed

+205
-0
lines changed

assets/blocks/djot/index.js

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@
9999
const [ tableCols, setTableCols ] = useState( 3 );
100100
const [ tableRows, setTableRows ] = useState( 2 );
101101
const [ cursorInTable, setCursorInTable ] = useState( false );
102+
const [ showImportModal, setShowImportModal ] = useState( false );
103+
const [ markdownInput, setMarkdownInput ] = useState( '' );
104+
const [ djotPreview, setDjotPreview ] = useState( '' );
102105
const textareaRef = useRef( null );
103106
const [ selectionStart, setSelectionStart ] = useState( 0 );
104107
const [ selectionEnd, setSelectionEnd ] = useState( 0 );
@@ -548,6 +551,153 @@
548551
restoreFocus( textarea, newCursorPos );
549552
}
550553

554+
// Convert Markdown to Djot
555+
function convertMarkdownToDjot( md ) {
556+
var result = md;
557+
558+
// Protect code blocks first (store and replace with placeholders)
559+
var codeBlocks = [];
560+
result = result.replace( /```[\s\S]*?```/g, function( match ) {
561+
codeBlocks.push( match );
562+
return '%%CODEBLOCK' + ( codeBlocks.length - 1 ) + '%%';
563+
} );
564+
565+
// Protect inline code
566+
var inlineCode = [];
567+
result = result.replace( /`[^`]+`/g, function( match ) {
568+
inlineCode.push( match );
569+
return '%%INLINECODE' + ( inlineCode.length - 1 ) + '%%';
570+
} );
571+
572+
// Convert indented code blocks to fenced (4 spaces or 1 tab)
573+
result = result.replace( /^((?:(?: |\t).+\n?)+)/gm, function( match ) {
574+
var code = match.replace( /^( |\t)/gm, '' ).trimEnd();
575+
return '```\n' + code + '\n```\n';
576+
} );
577+
578+
// Bold: **text** or __text__ → *text*
579+
result = result.replace( /\*\*([^*]+)\*\*/g, '*$1*' );
580+
result = result.replace( /__([^_]+)__/g, '*$1*' );
581+
582+
// Italic: *text* or _text_ → _text_ (but not inside words)
583+
// Only convert *text* that's not already bold
584+
result = result.replace( /(?<!\*)\*([^*]+)\*(?!\*)/g, '_$1_' );
585+
586+
// Strikethrough: ~~text~~ → {~text~}
587+
result = result.replace( /~~([^~]+)~~/g, '{~$1~}' );
588+
589+
// Highlight: ==text== → {=text=}
590+
result = result.replace( /==([^=]+)==/g, '{=$1=}' );
591+
592+
// Headers: ensure space after # (Djot requires it)
593+
result = result.replace( /^(#{1,6})([^ #\n])/gm, '$1 $2' );
594+
595+
// Remove trailing # from headers (Djot treats them as content)
596+
result = result.replace( /^(#{1,6} .+?)\s*#+\s*$/gm, '$1' );
597+
598+
// Setext-style headers: convert to ATX style
599+
// H1: text followed by line of ===
600+
result = result.replace( /^(.+)\n=+$/gm, '# $1' );
601+
// H2: text followed by line of ---
602+
result = result.replace( /^(.+)\n-+$/gm, '## $1' );
603+
604+
// Ensure blank line after headers (Djot requires it)
605+
result = result.replace( /^(#{1,6} .+)$(\n?)(?!\n)/gm, '$1\n\n' );
606+
607+
// Link titles: [text](url "title") → [text](url){title="title"}
608+
result = result.replace( /\[([^\]]+)\]\(([^)"]+)\s+"([^"]+)"\)/g, '[$1]($2){title="$3"}' );
609+
result = result.replace( /\[([^\]]+)\]\(([^)']+)\s+'([^']+)'\)/g, "[$1]($2){title='$3'}" );
610+
611+
// Hard line breaks: trailing two spaces → backslash
612+
result = result.replace( / $/gm, '\\' );
613+
614+
// Blockquotes: ensure space after > (unless followed by newline)
615+
result = result.replace( /^>([^ \n>])/gm, '> $1' );
616+
617+
// Ensure blank line before blockquotes
618+
result = result.replace( /([^\n])\n(>)/gm, '$1\n\n$2' );
619+
620+
// Ensure blank line before lists
621+
result = result.replace( /([^\n])\n([-*+] |\d+\. )/gm, '$1\n\n$2' );
622+
623+
// Raw HTML: wrap in djot raw syntax
624+
result = result.replace( /<([a-z][a-z0-9]*)([ >])/gi, function( match, tag, after ) {
625+
// Skip common safe tags that might be intentional
626+
var safeTags = [ 'http', 'https' ];
627+
if ( safeTags.indexOf( tag.toLowerCase() ) >= 0 ) {
628+
return match;
629+
}
630+
return '`<' + tag + after.trimEnd() + '`{=html}';
631+
} );
632+
633+
// Restore inline code
634+
inlineCode.forEach( function( code, idx ) {
635+
result = result.replace( '%%INLINECODE' + idx + '%%', code );
636+
} );
637+
638+
// Restore code blocks
639+
codeBlocks.forEach( function( block, idx ) {
640+
result = result.replace( '%%CODEBLOCK' + idx + '%%', block );
641+
} );
642+
643+
// Clean up excessive blank lines (more than 2 consecutive)
644+
result = result.replace( /\n{3,}/g, '\n\n' );
645+
646+
return result;
647+
}
648+
649+
// Open import modal
650+
function onImportMarkdown() {
651+
setMarkdownInput( '' );
652+
setDjotPreview( '' );
653+
setShowImportModal( true );
654+
}
655+
656+
// Update preview when markdown input changes
657+
function onMarkdownInputChange( value ) {
658+
setMarkdownInput( value );
659+
setDjotPreview( convertMarkdownToDjot( value ) );
660+
}
661+
662+
// Insert converted djot at cursor position
663+
function onInsertImported() {
664+
if ( ! djotPreview.trim() ) {
665+
setShowImportModal( false );
666+
return;
667+
}
668+
669+
var textarea = textareaRef.current ? textareaRef.current.querySelector( 'textarea' ) : null;
670+
var text = content || '';
671+
var start = textarea ? textarea.selectionStart : text.length;
672+
673+
// Add newlines if needed
674+
var prefix = '';
675+
if ( start > 0 && text[ start - 1 ] !== '\n' ) {
676+
prefix = '\n\n';
677+
} else if ( start > 1 && text[ start - 2 ] !== '\n' ) {
678+
prefix = '\n';
679+
}
680+
681+
var suffix = '\n\n';
682+
if ( start < text.length - 1 && text[ start ] === '\n' && text[ start + 1 ] === '\n' ) {
683+
suffix = '';
684+
} else if ( start < text.length && text[ start ] === '\n' ) {
685+
suffix = '\n';
686+
}
687+
688+
var newText = text.substring( 0, start ) + prefix + djotPreview + suffix + text.substring( start );
689+
var newCursorPos = start + prefix.length + djotPreview.length;
690+
691+
setAttributes( { content: newText } );
692+
setShowImportModal( false );
693+
setMarkdownInput( '' );
694+
setDjotPreview( '' );
695+
696+
if ( textarea ) {
697+
restoreFocus( textarea, newCursorPos );
698+
}
699+
}
700+
551701
// Keyboard shortcut handler for textarea
552702
function handleTextareaKeyDown( e ) {
553703
const isMod = e.ctrlKey || e.metaKey;
@@ -882,6 +1032,15 @@
8821032
wp.element.createElement( 'div', null, wp.element.createElement( 'kbd', null, 'Ctrl+Shift+X' ), ' Strikethrough' ),
8831033
wp.element.createElement( 'div', null, wp.element.createElement( 'kbd', null, 'ESC' ), ' Exit preview' )
8841034
)
1035+
),
1036+
wp.element.createElement(
1037+
PanelBody,
1038+
{ title: __( 'Tools', 'wp-djot' ), initialOpen: false },
1039+
wp.element.createElement( Button, {
1040+
variant: 'secondary',
1041+
onClick: onImportMarkdown,
1042+
style: { width: '100%' },
1043+
}, __( 'Import Markdown', 'wp-djot' ) )
8851044
)
8861045
),
8871046
// Link Modal
@@ -983,6 +1142,52 @@
9831142
}, __( 'Cancel', 'wp-djot' ) )
9841143
)
9851144
),
1145+
// Import Markdown Modal
1146+
showImportModal && wp.element.createElement(
1147+
Modal,
1148+
{
1149+
title: __( 'Import Markdown', 'wp-djot' ),
1150+
onRequestClose: function() { setShowImportModal( false ); },
1151+
style: { width: '600px', maxWidth: '90vw' },
1152+
},
1153+
wp.element.createElement( 'div', { style: { display: 'flex', gap: '16px' } },
1154+
wp.element.createElement( 'div', { style: { flex: 1 } },
1155+
wp.element.createElement( 'label', { style: { display: 'block', marginBottom: '8px', fontWeight: 600 } }, __( 'Markdown Input', 'wp-djot' ) ),
1156+
wp.element.createElement( 'textarea', {
1157+
value: markdownInput,
1158+
onChange: function( e ) { onMarkdownInputChange( e.target.value ); },
1159+
style: { width: '100%', height: '200px', fontFamily: 'monospace', fontSize: '13px', padding: '8px' },
1160+
placeholder: __( 'Paste your Markdown here...', 'wp-djot' ),
1161+
} )
1162+
),
1163+
wp.element.createElement( 'div', { style: { flex: 1 } },
1164+
wp.element.createElement( 'label', { style: { display: 'block', marginBottom: '8px', fontWeight: 600 } }, __( 'Djot Preview', 'wp-djot' ) ),
1165+
wp.element.createElement( 'textarea', {
1166+
value: djotPreview,
1167+
readOnly: true,
1168+
style: { width: '100%', height: '200px', fontFamily: 'monospace', fontSize: '13px', padding: '8px', background: '#f9f9f9' },
1169+
placeholder: __( 'Converted Djot will appear here...', 'wp-djot' ),
1170+
} )
1171+
)
1172+
),
1173+
wp.element.createElement( 'p', { style: { marginTop: '12px', fontSize: '12px', color: '#666' } },
1174+
__( 'Converts: **bold** → *bold*, *italic* → _italic_, ~~strike~~ → {~strike~}, ==highlight== → {=highlight=}', 'wp-djot' )
1175+
),
1176+
wp.element.createElement(
1177+
'div',
1178+
{ style: { marginTop: '16px' } },
1179+
wp.element.createElement( Button, {
1180+
variant: 'primary',
1181+
onClick: onInsertImported,
1182+
disabled: ! djotPreview.trim(),
1183+
}, __( 'Insert', 'wp-djot' ) ),
1184+
wp.element.createElement( Button, {
1185+
variant: 'secondary',
1186+
onClick: function() { setShowImportModal( false ); },
1187+
style: { marginLeft: '8px' },
1188+
}, __( 'Cancel', 'wp-djot' ) )
1189+
)
1190+
),
9861191
// Main content area
9871192
content || isPreviewMode
9881193
? wp.element.createElement(

0 commit comments

Comments
 (0)