atproto-based blog in ruby
at main 9.8 kB view raw
1<!-- This file is written by ChatGPT. I am lazy. --> 2<!DOCTYPE html> 3<html lang="en"> 4<head> 5 <meta charset="UTF-8"> 6 <title>Blog Post Editor</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> 55</head> 56<body> 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> 62 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> 68 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 }); 81 82 // Create a markdown-it instance. 83 const md = window.markdownit({ 84 html: false, 85 linkify: true, 86 typographer: true, 87 }); 88 89 // Update the JSON output on editor changes or title changes. 90 document.getElementById('title').addEventListener('input', updateJson); 91 editor.on('change', updateJson); 92 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 }); 104 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); 112 113 const jsonOutput = { 114 "$type": "net.shreyanjain.blog.post", 115 "title": title, 116 "content": content, 117 "createdAt": new Date().toISOString() 118 }; 119 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.richtext.block#heading" 153 : "net.shreyanjain.richtext.block#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.richtext.block#paragraph" 167 }; 168 blocks.push(block); 169 i += 3; // skip paragraph_open, inline, paragraph_close 170 continue; 171 } 172 173 // Process code blocks (fence) 174 if(token.type === 'fence') { 175 const block = { 176 "text": token.content, 177 "$type": "net.shreyanjain.richtext.block#code" 178 }; 179 blocks.push(block); 180 i++; 181 continue; 182 } 183 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.richtext.block#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.richtext.formatting#code" } 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.richtext.formatting#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.richtext.formatting#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.richtext.formatting#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> 300</body> 301</html>