+913
whtwnd-to-leaflet.html
+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>