A WhiteWind blog to Leaflet publication conversion tool

Create whtwnd-to-leaflet.html

authored by ewancroft.uk and committed by GitHub cc855634 5adfb71a

Changed files
+913
+913
whtwnd-to-leaflet.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>WhiteWind to Leaflet Converter</title> 7 + <style> 8 + /* 9 + * General Styles 10 + * -------------- 11 + * Sets up basic typography, colors, and layout for a clean, modern look. 12 + */ 13 + * { 14 + margin: 0; 15 + padding: 0; 16 + box-sizing: border-box; 17 + } 18 + 19 + body { 20 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 21 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 22 + min-height: 100vh; 23 + padding: 20px; 24 + } 25 + 26 + .container { 27 + max-width: 1200px; 28 + margin: 0 auto; 29 + background: white; 30 + border-radius: 16px; 31 + box-shadow: 0 20px 40px rgba(0,0,0,0.1); 32 + overflow: hidden; 33 + } 34 + 35 + .header { 36 + background: linear-gradient(135deg, #2d3748 0%, #4a5568 100%); 37 + color: white; 38 + padding: 30px; 39 + text-align: center; 40 + } 41 + 42 + .header h1 { 43 + font-size: 2.5rem; 44 + margin-bottom: 10px; 45 + font-weight: 700; 46 + } 47 + 48 + .header p { 49 + opacity: 0.9; 50 + font-size: 1.1rem; 51 + } 52 + 53 + /* 54 + * Section and Step Styles 55 + * ----------------------- 56 + * Defines the layout and appearance of each step in the conversion process. 57 + */ 58 + .main-content { 59 + padding: 40px; 60 + } 61 + 62 + .step { 63 + margin-bottom: 40px; 64 + padding: 30px; 65 + border: 2px solid #e2e8f0; 66 + border-radius: 12px; 67 + transition: all 0.3s ease; 68 + } 69 + 70 + .step:hover { 71 + border-color: #667eea; 72 + box-shadow: 0 8px 25px rgba(102, 126, 234, 0.15); 73 + } 74 + 75 + .step h2 { 76 + color: #2d3748; 77 + margin-bottom: 20px; 78 + font-size: 1.5rem; 79 + display: flex; 80 + align-items: center; 81 + } 82 + 83 + .step-number { 84 + background: linear-gradient(135deg, #667eea, #764ba2); 85 + color: white; 86 + width: 35px; 87 + height: 35px; 88 + border-radius: 50%; 89 + display: flex; 90 + align-items: center; 91 + justify-content: center; 92 + margin-right: 15px; 93 + font-weight: bold; 94 + } 95 + 96 + /* 97 + * Form Element Styles 98 + * ------------------- 99 + * Styles for input fields, text areas, and buttons. 100 + */ 101 + .form-group { 102 + margin-bottom: 20px; 103 + } 104 + 105 + label { 106 + display: block; 107 + margin-bottom: 8px; 108 + font-weight: 600; 109 + color: #374151; 110 + } 111 + 112 + input, textarea, select { 113 + width: 100%; 114 + padding: 12px 16px; 115 + border: 2px solid #e5e7eb; 116 + border-radius: 8px; 117 + font-size: 16px; 118 + transition: border-color 0.3s ease; 119 + } 120 + 121 + input:focus, textarea:focus, select:focus { 122 + outline: none; 123 + border-color: #667eea; 124 + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); 125 + } 126 + 127 + textarea { 128 + min-height: 120px; 129 + resize: vertical; 130 + } 131 + 132 + .textarea-large { 133 + min-height: 200px; 134 + } 135 + 136 + button { 137 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 138 + color: white; 139 + border: none; 140 + padding: 14px 28px; 141 + border-radius: 8px; 142 + font-size: 16px; 143 + font-weight: 600; 144 + cursor: pointer; 145 + transition: all 0.3s ease; 146 + } 147 + 148 + button:hover { 149 + transform: translateY(-2px); 150 + box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3); 151 + } 152 + 153 + button:disabled { 154 + opacity: 0.6; 155 + cursor: not-allowed; 156 + transform: none; 157 + } 158 + 159 + /* 160 + * Output and Message Styles 161 + * ------------------------- 162 + * Styling for the output sections and user feedback messages. 163 + */ 164 + .output { 165 + background: #f8fafc; 166 + border: 2px solid #e2e8f0; 167 + border-radius: 8px; 168 + padding: 20px; 169 + margin-top: 20px; 170 + } 171 + 172 + .output pre { 173 + background: #1a202c; 174 + color: #e2e8f0; 175 + padding: 20px; 176 + border-radius: 6px; 177 + overflow-x: auto; 178 + font-size: 14px; 179 + line-height: 1.5; 180 + } 181 + 182 + .warning { 183 + background: #fef3cd; 184 + border: 1px solid #fde68a; 185 + color: #92400e; 186 + padding: 15px; 187 + border-radius: 6px; 188 + margin-bottom: 20px; 189 + } 190 + 191 + .success { 192 + background: #d1fae5; 193 + border: 1px solid #a7f3d0; 194 + color: #065f46; 195 + padding: 15px; 196 + border-radius: 6px; 197 + margin-bottom: 20px; 198 + } 199 + 200 + .copy-download-buttons { 201 + display: flex; 202 + gap: 10px; 203 + margin-top: 10px; 204 + } 205 + 206 + .copy-button, .download-button { 207 + background: #10b981; 208 + font-size: 14px; 209 + padding: 8px 16px; 210 + } 211 + 212 + .download-button { 213 + background: #3b82f6; 214 + } 215 + 216 + .copy-button:hover, .download-button:hover { 217 + background: #059669; 218 + } 219 + 220 + .download-button:hover { 221 + background: #2563eb; 222 + } 223 + 224 + .grid { 225 + display: grid; 226 + grid-template-columns: 1fr 1fr; 227 + gap: 20px; 228 + } 229 + 230 + .example { 231 + background: #f1f5f9; 232 + padding: 15px; 233 + border-radius: 6px; 234 + margin-top: 10px; 235 + font-family: monospace; 236 + font-size: 12px; 237 + color: #64748b; 238 + } 239 + 240 + /* Responsive design for smaller screens */ 241 + @media (max-width: 768px) { 242 + .grid { 243 + grid-template-columns: 1fr; 244 + } 245 + 246 + .header h1 { 247 + font-size: 2rem; 248 + } 249 + 250 + .main-content { 251 + padding: 20px; 252 + } 253 + 254 + .copy-download-buttons { 255 + flex-direction: column; 256 + } 257 + } 258 + </style> 259 + </head> 260 + <body> 261 + <div class="container"> 262 + <div class="header"> 263 + <h1>🍃 WhiteWind → Leaflet Converter</h1> 264 + <p>Convert your WhiteWind blog entries to Leaflet publication format</p> 265 + </div> 266 + 267 + <div class="main-content"> 268 + <div class="step"> 269 + <h2><span class="step-number">1</span>Publication Setup</h2> 270 + <div class="grid"> 271 + <div class="form-group"> 272 + <label for="pubName">Publication Name*</label> 273 + <input type="text" id="pubName" placeholder="My Awesome Blog" required> 274 + </div> 275 + <div class="form-group"> 276 + <label for="basePath">Base Path</label> 277 + <input type="url" id="basePath" placeholder="https://myblog.com"> 278 + </div> 279 + </div> 280 + <div class="form-group"> 281 + <label for="pubDescription">Publication Description</label> 282 + <textarea id="pubDescription" placeholder="Describe your publication..."></textarea> 283 + </div> 284 + <div class="grid"> 285 + <div class="form-group"> 286 + <label for="showInDiscover">Show in Discover</label> 287 + <select id="showInDiscover"> 288 + <option value="true">Yes</option> 289 + <option value="false">No</option> 290 + </select> 291 + </div> 292 + <div class="form-group"> 293 + <label for="showComments">Enable Comments</label> 294 + <select id="showComments"> 295 + <option value="true">Yes</option> 296 + <option value="false">No</option> 297 + </select> 298 + </div> 299 + </div> 300 + </div> 301 + 302 + <div class="step"> 303 + <h2><span class="step-number">2</span>Theme Configuration</h2> 304 + <div class="grid"> 305 + <div class="form-group"> 306 + <label for="primaryColor">Primary Color</label> 307 + <input type="color" id="primaryColor" value="#667eea"> 308 + </div> 309 + <div class="form-group"> 310 + <label for="backgroundColor">Background Color</label> 311 + <input type="color" id="backgroundColor" value="#ffffff"> 312 + </div> 313 + </div> 314 + <div class="grid"> 315 + <div class="form-group"> 316 + <label for="pageBackground">Page Background</label> 317 + <input type="color" id="pageBackground" value="#f8fafc"> 318 + </div> 319 + <div class="form-group"> 320 + <label for="showPageBg">Show Page Background</label> 321 + <select id="showPageBg"> 322 + <option value="false">No</option> 323 + <option value="true">Yes</option> 324 + </select> 325 + </div> 326 + </div> 327 + </div> 328 + 329 + <div class="step"> 330 + <h2><span class="step-number">3</span>WhiteWind Blog Entries</h2> 331 + <div class="warning"> 332 + <strong>Note:</strong> Paste a JSON array of your WhiteWind blog entries below. The converter will automatically handle markdown parsing, AT-URI conversion, and schema transformation for all entries. 333 + </div> 334 + <div class="form-group"> 335 + <label for="whitewindJson">WhiteWind Entries JSON*</label> 336 + <textarea id="whitewindJson" class="textarea-large" placeholder='Paste your WhiteWind entries JSON array here...' required></textarea> 337 + <div class="example"> 338 + Example: [{"content": "# Post 1\n\nContent...", "title": "My First Post"}, {"content": "# Post 2\n\nMore content...", "title": "My Second Post"}] 339 + </div> 340 + </div> 341 + 342 + <div class="form-group"> 343 + <label for="authorDid">Author DID*</label> 344 + <input type="text" id="authorDid" placeholder="did:plc:..." required> 345 + <div class="example"> 346 + Format: did:plc:example123... or did:web:example.com 347 + </div> 348 + </div> 349 + 350 + <button onclick="convertEntries()" id="convertBtn">🔄 Convert to Leaflet</button> 351 + </div> 352 + 353 + <div class="step" id="outputSection" style="display: none;"> 354 + <h2><span class="step-number">4</span>Converted Output</h2> 355 + <div class="success" id="successMessage" style="display: none;"> 356 + ✅ Conversion completed successfully! Copy or download the JSON below. 357 + </div> 358 + <div class="output"> 359 + <h3>Publication Record:</h3> 360 + <pre id="publicationOutput"></pre> 361 + <div class="copy-download-buttons"> 362 + <button class="copy-button" onclick="copyToClipboard('publicationOutput')">📋 Copy Publication</button> 363 + <button class="download-button" onclick="downloadFile('publicationOutput', 'publication.json')">⬇️ Download Publication</button> 364 + </div> 365 + </div> 366 + <div class="output"> 367 + <h3>Document Records:</h3> 368 + <pre id="documentOutput"></pre> 369 + <div class="copy-download-buttons"> 370 + <button class="copy-button" onclick="copyToClipboard('documentOutput')">📋 Copy Documents</button> 371 + <button class="download-button" onclick="downloadFile('documentOutput', 'documents.json')">⬇️ Download Documents</button> 372 + </div> 373 + </div> 374 + <div class="output"> 375 + <h3>Download as Zip:</h3> 376 + <p>Download all files (publication and documents) as a single ZIP archive, with files named `00.json` for the publication and `1.json`, `2.json`, etc., for each document.</p> 377 + <div class="copy-download-buttons"> 378 + <button class="download-button" id="zipDownloadBtn" onclick="downloadZip()">⬇️ Download ZIP</button> 379 + </div> 380 + </div> 381 + </div> 382 + </div> 383 + </div> 384 + 385 + <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> 386 + <script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script> 387 + <script> 388 + // Base32-sortable character set for TID encoding 389 + const BASE32_SORTABLE = '234567abcdefghijklmnopqrstuvwxyz'; 390 + 391 + /** 392 + * Generate a random 10-bit clock identifier 393 + */ 394 + function generateClockId() { 395 + return Math.floor(Math.random() * 1024); // 2^10 = 1024 396 + } 397 + 398 + /** 399 + * Convert a number to base32-sortable encoding 400 + */ 401 + function toBase32Sortable(num) { 402 + if (num === 0n) { 403 + return '2222222222222'; 404 + } 405 + 406 + let result = ''; 407 + while (num > 0n) { 408 + result = BASE32_SORTABLE[Number(num % 32n)] + result; 409 + num = num / 32n; 410 + } 411 + 412 + // Pad to 13 characters for consistent TID length 413 + return result.padStart(13, '2'); 414 + } 415 + 416 + /** 417 + * Generate a TID for the current timestamp 418 + */ 419 + function generateTID() { 420 + // Get current timestamp in microseconds since UNIX epoch 421 + const nowMs = Date.now(); 422 + const nowMicroseconds = BigInt(nowMs * 1000); // Convert to microseconds 423 + 424 + // Generate random clock identifier (10 bits) 425 + const clockId = generateClockId(); 426 + 427 + // Combine timestamp (53 bits) and clock identifier (10 bits) 428 + // The top bit is always 0, so we have 63 bits in total 429 + const tidBigInt = (nowMicroseconds << 10n) | BigInt(clockId); 430 + 431 + return toBase32Sortable(tidBigInt); 432 + } 433 + 434 + /** 435 + * Converts a hex color string to an RGB object. 436 + * @param {string} hex - The hex color code (e.g., "#ffffff"). 437 + * @returns {object|null} An object with r, g, and b properties, or null if invalid. 438 + */ 439 + function hexToRgb(hex) { 440 + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 441 + return result ? { 442 + r: parseInt(result[1], 16), 443 + g: parseInt(result[2], 16), 444 + b: parseInt(result[3], 16) 445 + } : null; 446 + } 447 + 448 + /** 449 + * Converts WhiteWind blob URLs to AT-URI format. 450 + * @param {string} url - The URL from the WhiteWind entry. 451 + * @param {string} did - The author's DID. 452 + * @returns {string} The converted AT-URI or the original URL if no match. 453 + */ 454 + function convertBlobUrlToAtUri(url, did) { 455 + const blobUrlRegex = /xrpc\/com\.atproto\.sync\.getBlob\?did=([^&]+)&cid=([^&\s]+)/; 456 + const match = url.match(blobUrlRegex); 457 + 458 + if (match) { 459 + const [, extractedDid, cid] = match; 460 + return `at://${decodeURIComponent(extractedDid)}/com.whtwnd.blog.entry/${cid}`; 461 + } 462 + 463 + if (url.includes('bafk') || url.includes('bafyb')) { 464 + const cidMatch = url.match(/(bafk[a-z0-9]+|bafyb[a-z0-9]+)/); 465 + if (cidMatch) { 466 + return `at://${did}/com.atproto.blob/${cidMatch[1]}`; 467 + } 468 + } 469 + 470 + return url; 471 + } 472 + 473 + /** 474 + * Parses a block of text for markdown facets (bold, italic, link, code) 475 + * and returns both the cleaned plaintext and the facet objects. 476 + * @param {string} text - The text to parse. 477 + * @param {string} authorDid - The author's DID for AT-URI conversion. 478 + * @returns {object} An object with `plaintext` and `facets` properties. 479 + */ 480 + function parseRichText(text, authorDid) { 481 + let plaintext = text; 482 + const facets = []; 483 + const utf8Encoder = new TextEncoder(); 484 + 485 + // Note: We need to handle links first, as they contain other syntax 486 + const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; 487 + let linkMatch; 488 + const linkReplacements = []; 489 + while ((linkMatch = linkRegex.exec(text)) !== null) { 490 + const fullMatch = linkMatch[0]; 491 + const linkText = linkMatch[1]; 492 + const uri = linkMatch[2]; 493 + const convertedUri = convertBlobUrlToAtUri(uri, authorDid); 494 + 495 + linkReplacements.push({ 496 + start: linkMatch.index, 497 + end: linkMatch.index + fullMatch.length, 498 + text: linkText, 499 + uri: convertedUri 500 + }); 501 + } 502 + 503 + // Apply replacements in reverse to avoid index shifting 504 + for (let i = linkReplacements.length - 1; i >= 0; i--) { 505 + const rep = linkReplacements[i]; 506 + const byteStart = utf8Encoder.encode(plaintext.substring(0, rep.start)).length; 507 + const byteEnd = byteStart + utf8Encoder.encode(rep.text).length; 508 + 509 + facets.push({ 510 + index: { byteStart, byteEnd }, 511 + features: [{ $type: 'pub.leaflet.richtext.facet#link', uri: rep.uri }] 512 + }); 513 + plaintext = plaintext.substring(0, rep.start) + rep.text + plaintext.substring(rep.end); 514 + } 515 + 516 + // Other facets on the cleaned plaintext 517 + const otherFacets = []; 518 + 519 + // Bold **text** 520 + let boldRegex = /\*\*([^*]+)\*\*/g; 521 + let boldMatch; 522 + while ((boldMatch = boldRegex.exec(plaintext)) !== null) { 523 + const start = utf8Encoder.encode(plaintext.substring(0, boldMatch.index)).length; 524 + const end = start + utf8Encoder.encode(boldMatch[1]).length; 525 + otherFacets.push({ 526 + index: { byteStart: start, byteEnd: end }, 527 + features: [{ $type: 'pub.leaflet.richtext.facet#bold' }] 528 + }); 529 + } 530 + 531 + // Italic *text* 532 + let italicRegex = /(?<!\*)\*([^*]+)\*(?!\*)/g; 533 + let italicMatch; 534 + while ((italicMatch = italicRegex.exec(plaintext)) !== null) { 535 + const start = utf8Encoder.encode(plaintext.substring(0, italicMatch.index)).length; 536 + const end = start + utf8Encoder.encode(italicMatch[1]).length; 537 + otherFacets.push({ 538 + index: { byteStart: start, byteEnd: end }, 539 + features: [{ $type: 'pub.leaflet.richtext.facet#italic' }] 540 + }); 541 + } 542 + 543 + // Inline code `text` 544 + let codeRegex = /`([^`]+)`/g; 545 + let codeMatch; 546 + while ((codeMatch = codeRegex.exec(plaintext)) !== null) { 547 + const start = utf8Encoder.encode(plaintext.substring(0, codeMatch.index)).length; 548 + const end = start + utf8Encoder.encode(codeMatch[1]).length; 549 + otherFacets.push({ 550 + index: { byteStart: start, byteEnd: end }, 551 + features: [{ $type: 'pub.leaflet.richtext.facet#code' }] 552 + }); 553 + } 554 + 555 + // Combine all facets and sort them by start index 556 + const allFacets = [...facets, ...otherFacets]; 557 + allFacets.sort((a, b) => a.index.byteStart - b.index.byteStart); 558 + 559 + // Clean up the plaintext from bold, italic, and code markdown 560 + plaintext = plaintext.replace(boldRegex, '$1'); 561 + plaintext = plaintext.replace(italicRegex, '$1'); 562 + plaintext = plaintext.replace(codeRegex, '$1'); 563 + 564 + return { plaintext, facets: allFacets.length > 0 ? allFacets : undefined }; 565 + } 566 + 567 + /** 568 + * Parses markdown content into a series of Leaflet document blocks. 569 + * @param {string} content - The markdown content to parse. 570 + * @param {string} authorDid - The author's DID for AT-URI conversion. 571 + * @returns {Array} An array of Leaflet block objects. 572 + */ 573 + function parseMarkdownToBlocks(content, authorDid) { 574 + const blocks = []; 575 + const lines = content.split('\n'); 576 + let currentBlock = ''; 577 + let blockType = 'text'; 578 + 579 + for (let i = 0; i < lines.length; i++) { 580 + const line = lines[i]; 581 + 582 + if (line.startsWith('#')) { 583 + if (currentBlock.trim()) { 584 + const { plaintext, facets } = parseRichText(currentBlock.trim(), authorDid); 585 + blocks.push(createBlock(blockType, plaintext, authorDid, { facets })); 586 + currentBlock = ''; 587 + } 588 + const level = line.match(/^#+/)[0].length; 589 + const text = line.replace(/^#+\s*/, ''); 590 + const { plaintext, facets } = parseRichText(text, authorDid); 591 + blocks.push(createBlock('header', plaintext, authorDid, { level, facets })); 592 + continue; 593 + } 594 + 595 + if (line.match(/^[-*_]{3,}$/)) { 596 + if (currentBlock.trim()) { 597 + const { plaintext, facets } = parseRichText(currentBlock.trim(), authorDid); 598 + blocks.push(createBlock(blockType, plaintext, authorDid, { facets })); 599 + currentBlock = ''; 600 + } 601 + blocks.push(createBlock('horizontalRule', '', authorDid)); 602 + continue; 603 + } 604 + 605 + if (line.startsWith('```')) { 606 + if (blockType === 'code') { 607 + blocks.push(createBlock('code', currentBlock, authorDid, { 608 + language: 'javascript' 609 + })); 610 + currentBlock = ''; 611 + blockType = 'text'; 612 + } else { 613 + if (currentBlock.trim()) { 614 + const { plaintext, facets } = parseRichText(currentBlock.trim(), authorDid); 615 + blocks.push(createBlock(blockType, plaintext, authorDid, { facets })); 616 + currentBlock = ''; 617 + } 618 + blockType = 'code'; 619 + } 620 + continue; 621 + } 622 + 623 + if (line.startsWith('>')) { 624 + if (blockType !== 'blockquote') { 625 + if (currentBlock.trim()) { 626 + const { plaintext, facets } = parseRichText(currentBlock.trim(), authorDid); 627 + blocks.push(createBlock(blockType, plaintext, authorDid, { facets })); 628 + currentBlock = ''; 629 + } 630 + blockType = 'blockquote'; 631 + } 632 + currentBlock += line.replace(/^>\s*/, '') + '\n'; 633 + continue; 634 + } 635 + 636 + const imgMatch = line.match(/!\[([^\]]*)\]\(([^)]+)\)/); 637 + if (imgMatch) { 638 + if (currentBlock.trim()) { 639 + const { plaintext, facets } = parseRichText(currentBlock.trim(), authorDid); 640 + blocks.push(createBlock(blockType, plaintext, authorDid, { facets })); 641 + currentBlock = ''; 642 + } 643 + const [, alt, src] = imgMatch; 644 + const convertedSrc = convertBlobUrlToAtUri(src, authorDid); 645 + const { plaintext, facets } = parseRichText(`[Image: ${alt || 'Image'}] (${convertedSrc})`, authorDid); 646 + blocks.push(createBlock('text', plaintext, authorDid, { facets })); 647 + blockType = 'text'; 648 + continue; 649 + } 650 + 651 + if (!line.trim()) { 652 + if (currentBlock.trim()) { 653 + const { plaintext, facets } = parseRichText(currentBlock.trim(), authorDid); 654 + blocks.push(createBlock(blockType, plaintext, authorDid, { facets })); 655 + currentBlock = ''; 656 + blockType = 'text'; 657 + } 658 + continue; 659 + } 660 + 661 + if (blockType !== 'text' && blockType !== 'blockquote' && blockType !== 'code') { 662 + blockType = 'text'; 663 + } 664 + 665 + currentBlock += line + '\n'; 666 + } 667 + 668 + if (currentBlock.trim()) { 669 + const { plaintext, facets } = parseRichText(currentBlock.trim(), authorDid); 670 + blocks.push(createBlock(blockType, plaintext, authorDid, { facets })); 671 + } 672 + 673 + return blocks.length > 0 ? blocks : [createBlock('text', content, authorDid)]; 674 + } 675 + 676 + /** 677 + * Creates a single Leaflet block object with the correct schema and content. 678 + * @param {string} type - The type of block (e.g., 'text', 'header'). 679 + * @param {string} content - The plaintext content of the block. 680 + * @param {string} authorDid - The author's DID for facet parsing. 681 + * @param {object} options - Additional options for the block (e.g., header level, facets). 682 + * @returns {object} A Leaflet block object. 683 + */ 684 + function createBlock(type, content, authorDid, options = {}) { 685 + const block = { 686 + block: {} 687 + }; 688 + 689 + switch (type) { 690 + case 'header': 691 + block.block = { 692 + $type: 'pub.leaflet.blocks.header', 693 + level: options.level || 1, 694 + plaintext: content, 695 + ...(options.facets && { facets: options.facets }) 696 + }; 697 + break; 698 + case 'blockquote': 699 + block.block = { 700 + $type: 'pub.leaflet.blocks.blockquote', 701 + plaintext: content, 702 + ...(options.facets && { facets: options.facets }) 703 + }; 704 + break; 705 + case 'code': 706 + block.block = { 707 + $type: 'pub.leaflet.blocks.code', 708 + plaintext: content, 709 + language: options.language || 'javascript' 710 + }; 711 + break; 712 + case 'horizontalRule': 713 + block.block = { 714 + $type: 'pub.leaflet.blocks.horizontalRule' 715 + }; 716 + break; 717 + default: 718 + block.block = { 719 + $type: 'pub.leaflet.blocks.text', 720 + plaintext: content, 721 + ...(options.facets && { facets: options.facets }) 722 + }; 723 + } 724 + return block; 725 + } 726 + 727 + /** 728 + * Main conversion function triggered by the button. 729 + * It validates input, parses the WhiteWind JSON, converts each entry 730 + * to a Leaflet document record, and displays the results. 731 + */ 732 + async function convertEntries() { 733 + const button = document.getElementById('convertBtn'); 734 + button.disabled = true; 735 + button.textContent = '🔄 Converting...'; 736 + 737 + try { 738 + // Get form data 739 + const pubName = document.getElementById('pubName').value.trim(); 740 + const basePath = document.getElementById('basePath').value.trim(); 741 + const pubDescription = document.getElementById('pubDescription').value.trim(); 742 + const showInDiscover = document.getElementById('showInDiscover').value === 'true'; 743 + const showComments = document.getElementById('showComments').value === 'true'; 744 + const authorDid = document.getElementById('authorDid').value.trim(); 745 + const whitewindJson = document.getElementById('whitewindJson').value.trim(); 746 + 747 + // Input validation 748 + if (!pubName || !authorDid || !whitewindJson) { 749 + throw new Error('Please fill in all required fields (marked with *)'); 750 + } 751 + 752 + let whitewindEntries; 753 + try { 754 + const parsedJson = JSON.parse(whitewindJson); 755 + // Check if the JSON is an object with a 'records' key or a direct array 756 + if (parsedJson && typeof parsedJson === 'object' && parsedJson.records && Array.isArray(parsedJson.records)) { 757 + whitewindEntries = parsedJson.records; 758 + } else if (Array.isArray(parsedJson)) { 759 + whitewindEntries = parsedJson; 760 + } else { 761 + throw new Error('Invalid JSON input. Please provide a JSON array or an object with a "records" key containing an array.'); 762 + } 763 + } catch (e) { 764 + throw new Error('Invalid JSON input. Please provide a valid JSON array or object with a "records" key.'); 765 + } 766 + 767 + // Generate a unique TID for the publication record 768 + const rkey = generateTID(); 769 + 770 + // Generate the main publication record 771 + const primaryRgb = hexToRgb(document.getElementById('primaryColor').value); 772 + const backgroundRgb = hexToRgb(document.getElementById('backgroundColor').value); 773 + const pageBackgroundRgb = hexToRgb(document.getElementById('pageBackground').value); 774 + 775 + const publicationRecord = { 776 + $type: 'pub.leaflet.publication', 777 + rkey: rkey, 778 + name: pubName, 779 + ...(basePath && { base_path: basePath }), 780 + ...(pubDescription && { description: pubDescription }), 781 + preferences: { 782 + showInDiscover, 783 + showComments 784 + }, 785 + theme: { 786 + primary: { $type: 'pub.leaflet.theme.color#rgb', ...primaryRgb }, 787 + backgroundColor: { $type: 'pub.leaflet.theme.color#rgb', ...backgroundRgb }, 788 + pageBackground: { $type: 'pub.leaflet.theme.color#rgb', ...pageBackgroundRgb }, 789 + showPageBackground: document.getElementById('showPageBg').value === 'true' 790 + } 791 + }; 792 + 793 + // Generate document records for each entry in the input array 794 + const documentRecords = whitewindEntries.map(entry => { 795 + const content = entry.value?.content || entry.content; 796 + const title = entry.value?.title || entry.title; 797 + const subtitle = entry.value?.subtitle || entry.subtitle; 798 + const createdAt = entry.value?.createdAt || entry.createdAt; 799 + 800 + // Check for required 'content' field 801 + if (!content) { 802 + throw new Error('One or more WhiteWind entries is missing a "content" field'); 803 + } 804 + const blocks = parseMarkdownToBlocks(content, authorDid); 805 + const publicationUri = `at://${authorDid}/pub.leaflet.publication/${rkey}`; 806 + 807 + return { 808 + $type: 'pub.leaflet.document', 809 + title: title || 'Untitled Post', 810 + ...(subtitle && { description: subtitle }), 811 + author: authorDid, 812 + publication: publicationUri, 813 + ...(createdAt && { publishedAt: createdAt }), 814 + pages: [{ 815 + $type: 'pub.leaflet.pages.linearDocument', 816 + blocks: blocks 817 + }] 818 + }; 819 + }); 820 + 821 + // Display results and enable output section 822 + document.getElementById('publicationOutput').textContent = JSON.stringify(publicationRecord, null, 2); 823 + document.getElementById('documentOutput').textContent = JSON.stringify(documentRecords, null, 2); 824 + document.getElementById('successMessage').style.display = 'block'; 825 + document.getElementById('outputSection').style.display = 'block'; 826 + 827 + // Scroll to the output section for better user experience 828 + document.getElementById('outputSection').scrollIntoView({ behavior: 'smooth' }); 829 + 830 + } catch (error) { 831 + // Display error message to the user 832 + alert('Error: ' + error.message); 833 + } finally { 834 + // Reset button state regardless of success or failure 835 + button.disabled = false; 836 + button.textContent = '🔄 Convert to Leaflet'; 837 + } 838 + } 839 + 840 + /** 841 + * Copies the content of a specified HTML element to the clipboard. 842 + * @param {string} elementId - The ID of the element to copy. 843 + */ 844 + function copyToClipboard(elementId) { 845 + const element = document.getElementById(elementId); 846 + const text = element.textContent; 847 + 848 + navigator.clipboard.writeText(text).then(() => { 849 + const button = event.target; 850 + const originalText = button.textContent; 851 + button.textContent = '✅ Copied!'; 852 + setTimeout(() => { 853 + button.textContent = originalText; 854 + }, 2000); 855 + }).catch(err => { 856 + console.error('Failed to copy: ', err); 857 + alert('Failed to copy to clipboard'); 858 + }); 859 + } 860 + 861 + /** 862 + * Downloads the content of a specified HTML element as a JSON file. 863 + * @param {string} elementId - The ID of the element to download. 864 + * @param {string} filename - The name for the downloaded file. 865 + */ 866 + function downloadFile(elementId, filename) { 867 + const element = document.getElementById(elementId); 868 + const content = element.textContent; 869 + 870 + const blob = new Blob([content], { type: 'application/json' }); 871 + const url = URL.createObjectURL(blob); 872 + 873 + const a = document.createElement('a'); 874 + a.href = url; 875 + a.download = filename; 876 + 877 + document.body.appendChild(a); 878 + a.click(); 879 + document.body.removeChild(a); 880 + 881 + URL.revokeObjectURL(url); 882 + 883 + const button = event.target; 884 + const originalText = button.textContent; 885 + button.textContent = '✅ Downloaded!'; 886 + setTimeout(() => { 887 + button.textContent = originalText; 888 + }, 2000); 889 + } 890 + 891 + /** 892 + * Creates a ZIP archive containing the publication record and each 893 + * document record, and then downloads it. 894 + */ 895 + function downloadZip() { 896 + const zip = new JSZip(); 897 + 898 + const publicationContent = document.getElementById('publicationOutput').textContent; 899 + zip.file('00.json', publicationContent); 900 + 901 + const documentContent = JSON.parse(document.getElementById('documentOutput').textContent); 902 + documentContent.forEach((doc, index) => { 903 + const filename = `${index + 1}.json`; 904 + zip.file(filename, JSON.stringify(doc, null, 2)); 905 + }); 906 + 907 + zip.generateAsync({ type: 'blob' }).then(function(content) { 908 + saveAs(content, 'leaflet_records.zip'); 909 + }); 910 + } 911 + </script> 912 + </body> 913 + </html>