atproto-based blog in ruby

new post editor

Changed files
+282 -98
public
+282 -98
public/converter.html
··· 1 1 <!-- This file is written by ChatGPT. I am lazy. --> 2 - 3 2 <!DOCTYPE html> 4 3 <html lang="en"> 5 4 <head> 6 - <meta charset="UTF-8"> 7 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 8 - <title>Markdown to Post Format</title> 9 - <script src="https://cdn.jsdelivr.net/npm/showdown@1.9.1/dist/showdown.min.js"></script> 5 + <meta charset="UTF-8"> 6 + <title>Markdown Editor to ATProto JSON</title> 7 + <!-- Toast UI Editor CSS --> 8 + <link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" /> 9 + <style> 10 + body { 11 + margin: 0; 12 + font-family: Arial, sans-serif; 13 + display: flex; 14 + height: 100vh; 15 + } 16 + .pane { 17 + flex: 1; 18 + padding: 1em; 19 + overflow-y: auto; 20 + border-right: 1px solid #ccc; 21 + } 22 + .json-pane { 23 + background: #f6f6f6; 24 + font-family: monospace; 25 + white-space: pre-wrap; 26 + padding: 1em; 27 + position: relative; 28 + } 29 + .title-input { 30 + width: calc(100% - 16px); 31 + margin: 0.5em; 32 + font-size: 1.2em; 33 + padding: 8px; 34 + } 35 + #editorContainer { 36 + height: calc(100% - 60px); 37 + } 38 + /* Copy button styling */ 39 + #copyButton { 40 + position: absolute; 41 + top: 10px; 42 + right: 10px; 43 + padding: 6px 12px; 44 + font-size: 0.9em; 45 + background-color: #007bff; 46 + color: white; 47 + border: none; 48 + border-radius: 4px; 49 + cursor: pointer; 50 + } 51 + #copyButton:hover { 52 + background-color: #0056b3; 53 + } 54 + </style> 10 55 </head> 11 56 <body> 12 - <h1>Markdown to Post Format Converter</h1> 57 + <!-- Left Pane: Markdown Editor --> 58 + <div class="pane"> 59 + <input type="text" id="title" class="title-input" placeholder="Enter Title Here" value="Nostr and ATProto - Part 1"> 60 + <div id="editorContainer"></div> 61 + </div> 13 62 14 - <textarea id="markdownInput" rows="10" cols="50" placeholder="Enter your Markdown here..."></textarea><br> 15 - <button id="convertButton">Convert</button> 63 + <!-- Right Pane: JSON Output with Copy Button --> 64 + <div class="pane json-pane" id="jsonOutput"> 65 + <button id="copyButton">Copy JSON</button> 66 + <!-- Live JSON output appears here --> 67 + </div> 16 68 17 - <h2>Converted Post Format:</h2> 18 - <pre id="output"></pre> 69 + <!-- Toast UI Editor JS --> 70 + <script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script> 71 + <!-- markdown-it for parsing markdown --> 72 + <script src="https://cdn.jsdelivr.net/npm/markdown-it@12.2.0/dist/markdown-it.min.js"></script> 73 + <script> 74 + // Initialize Toast UI Editor in Markdown mode. 75 + const editor = new toastui.Editor({ 76 + el: document.querySelector('#editorContainer'), 77 + initialEditType: 'markdown', 78 + previewStyle: 'vertical', 79 + height: '100%', 80 + }); 19 81 20 - <script> 21 - // Initialize Showdown (Markdown to HTML) 22 - const converter = new showdown.Converter(); 82 + // Create a markdown-it instance. 83 + const md = window.markdownit({ 84 + html: false, 85 + linkify: true, 86 + typographer: true, 87 + }); 23 88 24 - // Mapping Markdown to your custom format 25 - function convertMarkdownToCustomFormat(markdown) { 26 - const lines = markdown.split("\n"); 27 - const result = []; 89 + // Update the JSON output on editor changes or title changes. 90 + document.getElementById('title').addEventListener('input', updateJson); 91 + editor.on('change', updateJson); 28 92 29 - let currentParagraph = []; 30 - let currentFormatting = null; 93 + // Copy button event listener. 94 + document.getElementById('copyButton').addEventListener('click', () => { 95 + const jsonText = document.getElementById('jsonOutput').textContent; 96 + navigator.clipboard.writeText(jsonText) 97 + .then(() => { 98 + alert('JSON copied to clipboard!'); 99 + }) 100 + .catch(err => { 101 + alert('Failed to copy JSON: ' + err); 102 + }); 103 + }); 31 104 32 - lines.forEach(line => { 33 - if (line.startsWith('# ')) { 34 - // Heading 35 - result.push({ 36 - text: line.substring(2), 37 - $type: "net.shreyanjain.blog.richtext#heading" 38 - }); 39 - } else if (line.startsWith('## ')) { 40 - // Subheading 41 - result.push({ 42 - text: line.substring(3), 43 - $type: "net.shreyanjain.blog.richtext#subheading" 44 - }); 45 - } else if (line.startsWith('![')) { 46 - // Image (Markdown: ![alt text](url)) 47 - const altText = line.match(/\[([^\]]+)\]/)[1]; 48 - const url = line.match(/\(([^)]+)\)/)[1]; 49 - result.push({ 50 - text: { 51 - alt: altText, 52 - img: url, 53 - source: "url", 54 - dimensions: { width: 200, height: 150 } 55 - }, 56 - $type: "net.shreyanjain.blog.richtext#image" 57 - }); 58 - } else { 59 - // Paragraph or normal text 60 - const parts = line.split(/(\*\*.*?\*\*|\*.*?\*|_[^_]+_|~~.*?~~|\[.*?\]\(.*?\))/g); // Splits on Markdown formatting 105 + // The main conversion function: parses markdown to tokens and converts to our custom JSON. 106 + function updateJson() { 107 + const markdown = editor.getMarkdown(); 108 + const title = document.getElementById('title').value; 109 + // Parse the markdown into tokens using markdown-it. 110 + const tokens = md.parse(markdown, {}); 111 + const content = convertTokensToJson(tokens); 61 112 62 - parts.forEach(part => { 63 - if (part.startsWith("**")) { 64 - // Bold text 65 - currentParagraph.push({ 66 - content: part.slice(2, -2), 67 - formatting: [{ "$type": "net.shreyanjain.blog.richtext#bold" }] 68 - }); 69 - } else if (part.startsWith("*") || part.startsWith("_")) { 70 - // Italic text 71 - currentParagraph.push({ 72 - content: part.slice(1, -1), 73 - formatting: [{ "$type": "net.shreyanjain.blog.richtext#italic" }] 74 - }); 75 - } else if (part.startsWith("~~")) { 76 - // Strikethrough text (if desired) 77 - currentParagraph.push({ 78 - content: part.slice(2, -2), 79 - formatting: [{ "$type": "net.shreyanjain.blog.richtext#strikethrough" }] 80 - }); 81 - } else if (part.startsWith("[") && part.includes("](")) { 82 - // Link (Markdown: [text](url)) 83 - const linkText = part.match(/\[([^\]]+)\]/)[1]; 84 - const linkUrl = part.match(/\(([^)]+)\)/)[1]; 85 - currentParagraph.push({ 86 - content: linkText, 87 - formatting: [{ 88 - "$type": "net.shreyanjain.blog.richtext#link", 89 - "href": linkUrl 90 - }] 91 - }); 92 - } else if (part.trim()) { 93 - // Normal text 94 - currentParagraph.push({ content: part }); 95 - } 96 - }); 113 + const jsonOutput = { 114 + "$type": "net.shreyanjain.blog.post", 115 + "title": title, 116 + "content": content, 117 + "createdAt": new Date().toISOString() 118 + }; 97 119 98 - result.push({ 99 - text: currentParagraph, 100 - $type: "net.shreyanjain.blog.richtext#paragraph" 101 - }); 102 - currentParagraph = []; 103 - } 104 - }); 120 + document.getElementById('jsonOutput').childNodes.forEach(node => { 121 + if(node.nodeType === Node.TEXT_NODE) node.remove(); 122 + }); 123 + 124 + // Update the jsonOutput's text content (the copy button remains unaffected). 125 + document.getElementById('jsonOutput').lastChild.textContent = JSON.stringify(jsonOutput, null, 2); 126 + // Instead, update the JSON text by replacing all text except the copy button. 127 + const copyButton = document.getElementById('copyButton'); 128 + document.getElementById('jsonOutput').innerHTML = ""; 129 + document.getElementById('jsonOutput').appendChild(copyButton); 130 + const pre = document.createElement('pre'); 131 + pre.textContent = JSON.stringify(jsonOutput, null, 2); 132 + document.getElementById('jsonOutput').appendChild(pre); 133 + } 134 + 135 + // Converts markdown-it tokens into your custom JSON blocks. 136 + function convertTokensToJson(tokens) { 137 + const blocks = []; 138 + let i = 0; 139 + 140 + while(i < tokens.length) { 141 + const token = tokens[i]; 142 + 143 + // Process headings with raw text output. 144 + if(token.type === 'heading_open') { 145 + const level = parseInt(token.tag.slice(1)); 146 + const inlineToken = tokens[i+1]; // inline token holds the heading text 147 + // Process inline tokens and join them to a string. 148 + const textContent = processInline(inlineToken.children).map(seg => seg.content).join(''); 149 + const block = { 150 + "text": textContent, 151 + "$type": level === 1 152 + ? "net.shreyanjain.blog.richtext#heading" 153 + : "net.shreyanjain.blog.richtext#subheading" 154 + }; 155 + blocks.push(block); 156 + i += 3; // skip heading_open, inline, heading_close 157 + continue; 158 + } 159 + 160 + // Process paragraphs (still using content arrays). 161 + if(token.type === 'paragraph_open') { 162 + const inlineToken = tokens[i+1]; // inline token for paragraph 163 + const textBlocks = processInline(inlineToken.children); 164 + const block = { 165 + "text": textBlocks, 166 + "$type": "net.shreyanjain.blog.richtext#paragraph" 167 + }; 168 + blocks.push(block); 169 + i += 3; // skip paragraph_open, inline, paragraph_close 170 + continue; 171 + } 105 172 106 - return result; 173 + // Process code blocks (fence) 174 + if(token.type === 'fence') { 175 + const block = { 176 + "text": token.content, 177 + "$type": "net.shreyanjain.blog.richtext#code" 178 + }; 179 + blocks.push(block); 180 + i++; 181 + continue; 107 182 } 108 183 109 - // Handle the button click event 110 - document.getElementById("convertButton").addEventListener("click", () => { 111 - const markdownInput = document.getElementById("markdownInput").value; 112 - const customFormat = convertMarkdownToCustomFormat(markdownInput); 113 - document.getElementById("output").textContent = JSON.stringify(customFormat, null, 4); 114 - }); 115 - </script> 184 + // Process lists (both bullet and ordered) 185 + if(token.type === 'bullet_list_open' || token.type === 'ordered_list_open') { 186 + const listItems = []; 187 + i++; // advance into list items 188 + while(i < tokens.length && tokens[i].type !== 'bullet_list_close' && tokens[i].type !== 'ordered_list_close') { 189 + if(tokens[i].type === 'list_item_open') { 190 + // Each list item is expected to have a paragraph inside. 191 + i++; // move into list item content 192 + let itemContent = []; 193 + if(tokens[i].type === 'paragraph_open') { 194 + const inlineToken = tokens[i+1]; 195 + itemContent = processInline(inlineToken.children); 196 + i += 3; // skip paragraph tokens 197 + } 198 + listItems.push(itemContent); 199 + while(i < tokens.length && tokens[i].type !== 'list_item_close') { i++; } 200 + i++; // skip list_item_close 201 + } else { 202 + i++; 203 + } 204 + } 205 + // Wrap list items in a list block. 206 + const block = { 207 + "text": listItems, 208 + "$type": "net.shreyanjain.blog.richtext#list" 209 + }; 210 + blocks.push(block); 211 + i++; // skip list_close 212 + continue; 213 + } 214 + 215 + // If token not matched, advance. 216 + i++; 217 + } 218 + 219 + return blocks; 220 + } 221 + 222 + // Process inline tokens to extract text segments and formatting. 223 + function processInline(tokens) { 224 + const segments = []; 225 + let i = 0; 226 + 227 + while (i < tokens.length) { 228 + const token = tokens[i]; 229 + 230 + if (token.type === 'text') { 231 + segments.push({ "content": token.content }); 232 + i++; 233 + } else if (token.type === 'code_inline') { 234 + segments.push({ 235 + "content": token.content, 236 + "formatting": [ 237 + { "$type": "net.shreyanjain.blog.richtext#code-inline" } 238 + ] 239 + }); 240 + i++; 241 + } else if (token.type === 'em_open') { 242 + let content = ""; 243 + i++; 244 + while(i < tokens.length && tokens[i].type !== 'em_close') { 245 + if(tokens[i].content) content += tokens[i].content; 246 + i++; 247 + } 248 + i++; // Skip 'em_close' 249 + segments.push({ 250 + "content": content, 251 + "formatting": [ 252 + { "$type": "net.shreyanjain.blog.richtext#italic" } 253 + ] 254 + }); 255 + } else if (token.type === 'strong_open') { 256 + let content = ""; 257 + i++; 258 + while(i < tokens.length && tokens[i].type !== 'strong_close') { 259 + if(tokens[i].content) content += tokens[i].content; 260 + i++; 261 + } 262 + i++; // Skip 'strong_close' 263 + segments.push({ 264 + "content": content, 265 + "formatting": [ 266 + { "$type": "net.shreyanjain.blog.richtext#bold" } 267 + ] 268 + }); 269 + } else if (token.type === 'link_open') { 270 + let href = ""; 271 + if (token.attrs) { 272 + const hrefAttr = token.attrs.find(attr => attr[0] === 'href'); 273 + if (hrefAttr) href = hrefAttr[1]; 274 + } 275 + let content = ""; 276 + i++; 277 + while(i < tokens.length && tokens[i].type !== 'link_close') { 278 + if(tokens[i].content) content += tokens[i].content; 279 + i++; 280 + } 281 + i++; // Skip 'link_close' 282 + segments.push({ 283 + "content": content, 284 + "formatting": [ 285 + { "$type": "net.shreyanjain.blog.richtext#link", "href": href } 286 + ] 287 + }); 288 + } else { 289 + // Skip any unhandled tokens. 290 + i++; 291 + } 292 + } 293 + 294 + return segments; 295 + } 296 + 297 + // Initial call 298 + updateJson(); 299 + </script> 116 300 </body> 117 301 </html>