atproto-based blog in ruby
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>