-
-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathorg-social-validator.el
More file actions
618 lines (534 loc) · 24.3 KB
/
org-social-validator.el
File metadata and controls
618 lines (534 loc) · 24.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
;;; org-social-validator.el --- Validator for Org-social files -*- lexical-binding: t -*- -*- coding: utf-8 -*-
;; SPDX-License-Identifier: GPL-3.0
;; Author: Andros Fenollosa <hi@andros.dev>
;; Version: 2.12
;; URL: https://github.com/tanrax/org-social.el
;; This file is NOT part of GNU Emacs.
;; This program is free software; you can redistribute it and/or
;; modify it under the terms of the GNU General Public License as
;; published by the Free Software Foundation, either version 3 of the
;; License, or (at your option) any later version.
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see
;; <http://www.gnu.org/licenses/>.
;;; Commentary:
;; Comprehensive validator for Org Social files.
;; Validates structure, properties, and content according to Org Social specification.
;;; Code:
(require 'org)
(require 'org-element)
;;; Customization
(defgroup org-social-validator nil
"Validator for Org Social files."
:group 'org-social
:prefix "org-social-validator-")
;;; Error Reporting Variables
(defvar org-social-validator--current-file nil
"Current file being validated.")
(defvar org-social-validator--errors nil
"List of validation errors found.")
;;; Error Reporting Functions
(defun org-social-validator--error (line column message &optional suggestion)
"Report a validation error at LINE and COLUMN with MESSAGE.
Optional SUGGESTION provides a hint to fix the error."
(let ((error-info (list :line line
:column column
:message message
:suggestion suggestion
:context (org-social-validator--get-context line))))
(push error-info org-social-validator--errors)
error-info))
(defun org-social-validator--get-context (line)
"Get context around LINE in current buffer."
(save-excursion
(goto-char (point-min))
(forward-line (1- line))
(let ((line-start (line-beginning-position))
(line-end (line-end-position)))
(buffer-substring-no-properties line-start line-end))))
(defun org-social-validator--format-error (error)
"Format ERROR into a human-readable string."
(let ((line (plist-get error :line))
(column (plist-get error :column))
(message (plist-get error :message))
(suggestion (plist-get error :suggestion))
(context (plist-get error :context)))
(concat
(format "Line %d, column %d:\n" line column)
(format " %s\n" message)
(format " %s\n" context)
(format " %s^\n" (make-string (1- column) ?\s))
(when suggestion
(format " Hint: %s\n" suggestion)))))
(defun org-social-validator--display-errors ()
"Display all validation errors found in a split window."
(when org-social-validator--errors
(let ((buffer (get-buffer-create "*Org Social Validation*"))
(error-count (length org-social-validator--errors)))
(with-current-buffer buffer
(let ((inhibit-read-only t))
(erase-buffer)
(insert (format "⚠ Found %d validation issue(s) in %s\n\n"
error-count
(or org-social-validator--current-file "buffer")))
(insert "For more information about the Org Social specification, visit:\n")
(insert "https://org-social.org/\n\n")
(insert (make-string 70 ?─) "\n\n")
(dolist (error (reverse org-social-validator--errors))
(insert (org-social-validator--format-error error))
(insert "\n"))
(goto-char (point-min))
(special-mode)))
;; Display buffer in a split window
(let ((existing-window (get-buffer-window buffer)))
(if existing-window
;; If window already exists, just select it
(select-window existing-window)
;; Otherwise create a new split window
(let ((original-window (selected-window)))
(select-window (split-window-below))
(switch-to-buffer buffer)
(shrink-window-if-larger-than-buffer)
;; Return to original window
(select-window original-window)))))))
;;; Validation Rules
(defconst org-social-validator--required-keywords
'("TITLE" "NICK")
"List of required keywords in Org Social files.")
(defconst org-social-validator--known-keywords
'("TITLE" "NICK" "DESCRIPTION" "AVATAR" "LINK" "FOLLOW" "GROUP" "CONTACT")
"List of known Org Social keywords that will be validated.
Keywords not in this list will be ignored (not validated), allowing
integration with other `org-mode' tools and export features.")
(defconst org-social-validator--known-properties
'("ID" "LANG" "TAGS" "CLIENT" "REPLY_TO" "POLL_END" "POLL_OPTION" "GROUP" "MOOD"
"TITLE" "CATEGORY" "URL")
"List of known Org Social properties that will be validated.
Properties not in this list will be ignored (not validated), allowing
integration with other `org-mode' tools and features.")
(defconst org-social-validator--required-properties
'("ID")
"List of required properties in Org Social posts.")
(defconst org-social-validator--rfc3339-regexp
"^[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}T[0-9]\\{2\\}:[0-9]\\{2\\}:[0-9]\\{2\\}\\(\\+\\|-\\)[0-9]\\{2\\}:[0-9]\\{2\\}$\\|^[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}T[0-9]\\{2\\}:[0-9]\\{2\\}:[0-9]\\{2\\}\\(\\+\\|-\\)[0-9]\\{4\\}$"
"Regexp to validate RFC 3339 datetime format.")
;;; Validation Functions
(defun org-social-validator--validate-encoding ()
"Validate that buffer is UTF-8 encoded with LF line endings.
Accepts utf-8-unix, undecided-unix (ASCII/UTF-8 compatible), and
prefer-utf-8-unix."
(unless (memq buffer-file-coding-system '(utf-8-unix undecided-unix prefer-utf-8-unix))
(org-social-validator--error
1 1
(format "File encoding is %s, should be UTF-8 with LF line endings"
buffer-file-coding-system)
"Use `M-x set-buffer-file-coding-system RET utf-8-unix RET` to fix this")))
(defun org-social-validator--validate-keyword (keyword value line)
"Validate KEYWORD with VALUE at LINE."
(cond
;; TITLE validation
((string= keyword "TITLE")
(when (string-empty-p (string-trim value))
(org-social-validator--error
line 1
"TITLE cannot be empty"
"Add a descriptive title, e.g., #+TITLE: My Journal")))
;; NICK validation
((string= keyword "NICK")
(when (string-empty-p (string-trim value))
(org-social-validator--error
line 1
"NICK cannot be empty"
"Add a nickname without spaces, e.g., #+NICK: MyNick"))
(when (string-match-p " " value)
(org-social-validator--error
line 1
"NICK cannot contain spaces"
"Remove spaces from nickname, e.g., 'MyNick' instead of 'My Nick'")))
;; AVATAR validation
((string= keyword "AVATAR")
(unless (string-match-p "^https?://" value)
(org-social-validator--error
line 1
"AVATAR must be a valid URL starting with http:// or https://"
"Example: #+AVATAR: https://example.com/avatar.jpg"))
(unless (string-match-p "\\.\\(jpg\\|jpeg\\|png\\)$" (downcase value))
(org-social-validator--error
line 1
"AVATAR must be a JPG or PNG image"
"Use a square image at least 128x128 pixels in JPG or PNG format")))
;; LINK validation
((string= keyword "LINK")
(unless (string-match-p "^[a-zA-Z][a-zA-Z0-9+.-]*://" value)
(org-social-validator--error
line 1
"LINK must be a valid URI with a protocol"
"Example: #+LINK: https://example.com or gemini://example.com")))
;; FOLLOW validation
((string= keyword "FOLLOW")
(let ((parts (split-string value)))
(if (= (length parts) 1)
;; Only URL
(unless (string-match-p "^https?://" (car parts))
(org-social-validator--error
line 1
"FOLLOW URL must start with http:// or https://"
"Format: #+FOLLOW: https://example.com/social.org"))
;; Nickname + URL
(unless (string-match-p "^https?://" (cadr parts))
(org-social-validator--error
line 1
"FOLLOW URL must start with http:// or https://"
"Format: #+FOLLOW: nickname https://example.com/social.org")))))
;; GROUP validation
((string= keyword "GROUP")
(let ((parts (split-string value)))
(unless (>= (length parts) 2)
(org-social-validator--error
line 1
"GROUP must have format: <name> <relay-url>"
"Example: #+GROUP: Emacs Users https://example-relay.com"))
(when (>= (length parts) 2)
;; Last element should be the relay URL
(let ((relay-url (car (last parts))))
(unless (string-match-p "^https?://" relay-url)
(org-social-validator--error
line 1
"GROUP relay URL must start with http:// or https://"
"Example: #+GROUP: Emacs Users https://example-relay.com"))))))
;; CONTACT validation
((string= keyword "CONTACT")
(unless (string-match-p "^[a-zA-Z][a-zA-Z0-9+.-]*:" value)
(org-social-validator--error
line 1
"CONTACT must be a valid URI with a scheme"
"Examples: mailto:user@example.com, xmpp:user@server.org, https://mastodon.social/@user")))))
(defun org-social-validator--validate-property (property value post-line)
"Validate PROPERTY with VALUE at POST-LINE."
(cond
;; ID validation (required and RFC 3339 format)
((string= property "ID")
(unless (string-match org-social-validator--rfc3339-regexp value)
(org-social-validator--error
post-line 1
(format "Invalid ID format: %s" value)
"ID must be RFC 3339 format, e.g., 2025-05-01T12:00:00+0100 or 2025-05-01T12:00:00-0200")))
;; REPLY_TO validation
((string= property "REPLY_TO")
(unless (string-match "^https?://.*#[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}T" value)
(org-social-validator--error
post-line 1
"Invalid REPLY_TO format"
"Format: URL#ID, e.g., http://example.com/social.org#2025-05-01T12:00:00+0100")))
;; POLL_END validation
((string= property "POLL_END")
(unless (string-match org-social-validator--rfc3339-regexp value)
(org-social-validator--error
post-line 1
(format "Invalid POLL_END format: %s" value)
"POLL_END must be RFC 3339 format, e.g., 2025-05-01T12:00:00+0100")))
;; GROUP validation in properties
((string= property "GROUP")
(let ((parts (split-string value)))
(unless (>= (length parts) 2)
(org-social-validator--error
post-line 1
"GROUP property must have format: <name> <relay-url>"
"Example: :GROUP: Emacs Users https://example-relay.com"))
(when (>= (length parts) 2)
;; Last element should be the relay URL
(let ((relay-url (car (last parts))))
(unless (string-match-p "^https?://" relay-url)
(org-social-validator--error
post-line 1
"GROUP relay URL must start with http:// or https://"
"Example: :GROUP: Emacs Users https://example-relay.com"))))))
;; LANG validation
((string= property "LANG")
(unless (string-match-p "^[a-z]\\{2\\}$" value)
(org-social-validator--error
post-line 1
"LANG must be a two-letter ISO 639-1 code"
"Example: :LANG: en, :LANG: es, :LANG: fr")))
;; URL validation (for RSS/Atom mode)
((string= property "URL")
(unless (string-match-p "^https?://" value)
(org-social-validator--error
post-line 1
"URL must start with http:// or https://"
"Example: :URL: https://blog.example.com/article")))))
(defun org-social-validator--find-posts-section ()
"Find the '* Posts' section in the buffer."
(save-excursion
(goto-char (point-min))
(when (re-search-forward "^\\* Posts$" nil t)
(point))))
(defun org-social-validator--parse-global-keywords ()
"Parse and validate global keywords."
(let ((keywords '())
(found-required '()))
(save-excursion
(goto-char (point-min))
(while (re-search-forward "^#\\+\\([A-Z_]+\\):\\s-*\\(.+\\)$" nil t)
(let ((keyword (match-string 1))
(value (match-string 2))
(line (line-number-at-pos)))
;; Only validate known Org Social keywords
;; Unknown keywords are silently ignored (allows org-mode export keywords, etc.)
(when (member keyword org-social-validator--known-keywords)
;; Validate keyword
(org-social-validator--validate-keyword keyword value line))
;; Track required keywords
(when (member keyword org-social-validator--required-keywords)
(push keyword found-required))
;; Store keyword (even if unknown, for completeness)
(push (cons keyword value) keywords))))
;; Check for missing required keywords
(dolist (req org-social-validator--required-keywords)
(unless (member req found-required)
(org-social-validator--error
1 1
(format "Missing required keyword: #+%s:" req)
(format "Add #+%s: at the top of the file" req))))
(nreverse keywords)))
(defun org-social-validator--parse-post (element)
"Parse and validate a post ELEMENT."
(let* ((post-line (org-element-property :begin element))
(content-begin (org-element-property :contents-begin element))
(content-end (org-element-property :contents-end element))
(post-data '())
(found-properties '())
(id-in-header nil))
;; Check for ID in header (Org Social v1.6 format: ** TIMESTAMP)
(save-excursion
(goto-char post-line)
(beginning-of-line)
(when (looking-at "^\\*\\*\\s-+\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}T[0-9]\\{2\\}:[0-9]\\{2\\}:[0-9]\\{2\\}[+-][0-9]\\{2\\}\\(:[0-9]\\{2\\}\\|[0-9]\\{2\\}\\)\\)")
(setq id-in-header (match-string 1))
;; Validate the ID format
(unless (string-match org-social-validator--rfc3339-regexp id-in-header)
(org-social-validator--error
post-line 1
(format "Invalid ID format in header: %s" id-in-header)
"ID must be RFC 3339 format, e.g., 2025-05-01T12:00:00+0100 or 2025-05-01T12:00:00-0200"))
;; Mark ID as found
(push "ID" found-properties)))
;; Parse properties
(save-excursion
(goto-char post-line)
;; Search for properties drawer in the post (between begin and contents-end)
(when (re-search-forward ":PROPERTIES:" content-end t)
(let ((props-start (point))
(props-end (save-excursion
(when (re-search-forward "^:END:" content-end t)
(line-beginning-position)))))
(when props-end
(goto-char props-start)
(while (re-search-forward "^:\\([A-Z_]+\\):\\s-*\\(.+\\)$" props-end t)
(let ((prop (match-string 1))
(value (match-string 2)))
;; Only validate known Org Social properties
;; Unknown properties are silently ignored (allows org-mode export properties, etc.)
(when (member prop org-social-validator--known-properties)
;; Validate property
(org-social-validator--validate-property prop value post-line))
;; Track found properties
(when (member prop org-social-validator--required-properties)
(push prop found-properties))
;; Store property (even if unknown, for completeness)
(push (cons prop value) post-data)))))))
;; Check for missing required properties (ID can be in header OR properties)
(dolist (req org-social-validator--required-properties)
(unless (member req found-properties)
(org-social-validator--error
post-line 1
(format "Missing required property: :%s:" req)
"Every post must have an :ID: in the header (** TIMESTAMP) or as a property (:ID:)")))
;; Parse content
(when (and content-begin content-end)
(let ((content (buffer-substring-no-properties content-begin content-end)))
(push (cons 'content (string-trim content)) post-data)))
;; Validate poll structure if POLL_END is present
(when (assoc "POLL_END" post-data)
(org-social-validator--validate-poll post-line content-begin content-end))
(nreverse post-data)))
(defun org-social-validator--validate-poll (post-line content-begin content-end)
"Validate poll structure between CONTENT-BEGIN and CONTENT-END at POST-LINE."
(save-excursion
(goto-char content-begin)
(let ((found-checkbox nil))
(while (re-search-forward "^\\s-*-\\s-*\\[\\s-*\\]" content-end t)
(setq found-checkbox t))
(unless found-checkbox
(org-social-validator--error
post-line 1
"Poll post with :POLL_END: must contain checkbox list items"
"Add poll options like:\n- [ ] Option 1\n- [ ] Option 2")))))
(defun org-social-validator--parse-post-manually (post-begin)
"Parse and validate a post starting at POST-BEGIN using regex.
This is used when org-element cannot parse the headline
\(e.g., \\='**\\=' without space)."
(let ((post-line (line-number-at-pos post-begin))
(post-data '())
(found-properties '())
(id-in-header nil)
(post-end nil))
;; Find the end of this post (next ** or end of buffer)
(save-excursion
(goto-char post-begin)
(forward-line 1)
(if (re-search-forward "^\\*\\*\\($\\|[^*]\\)" nil t)
(setq post-end (line-beginning-position))
(setq post-end (point-max))))
;; Check for ID in header (v1.6 format)
(save-excursion
(goto-char post-begin)
(when (looking-at "^\\*\\*\\s-+\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}T[0-9]\\{2\\}:[0-9]\\{2\\}:[0-9]\\{2\\}[+-][0-9]\\{2\\}\\(:[0-9]\\{2\\}\\|[0-9]\\{2\\}\\)\\)")
(setq id-in-header (match-string 1))
(unless (string-match org-social-validator--rfc3339-regexp id-in-header)
(org-social-validator--error
post-line 1
(format "Invalid ID format in header: %s" id-in-header)
"ID must be RFC 3339 format, e.g., 2025-05-01T12:00:00+0100"))
(push "ID" found-properties)))
;; Parse properties
(save-excursion
(goto-char post-begin)
(when (re-search-forward ":PROPERTIES:" post-end t)
(let ((props-start (point))
(props-end (when (re-search-forward "^:END:" post-end t)
(line-beginning-position))))
(when props-end
(goto-char props-start)
(while (re-search-forward "^:\\([A-Z_]+\\):\\s-*\\(.+\\)$" props-end t)
(let ((prop (match-string 1))
(value (match-string 2)))
(when (member prop org-social-validator--known-properties)
(org-social-validator--validate-property prop value post-line))
(when (member prop org-social-validator--required-properties)
(push prop found-properties))
(push (cons prop value) post-data)))))))
;; Check for missing required properties
(dolist (req org-social-validator--required-properties)
(unless (member req found-properties)
(org-social-validator--error
post-line 1
(format "Missing required property: :%s:" req)
"Every post must have an :ID: in the header (** TIMESTAMP) or as a property (:ID:)")))
;; Validate poll if POLL_END is present
(when (assoc "POLL_END" post-data)
(save-excursion
(goto-char post-begin)
(forward-line 1)
(let ((content-start (point)))
(org-social-validator--validate-poll post-line content-start post-end))))
(nreverse post-data)))
(defun org-social-validator--parse-posts ()
"Parse and validate all posts in the '* Posts' section."
(let ((posts '())
(posts-start (org-social-validator--find-posts-section)))
(unless posts-start
(org-social-validator--error
1 1
"Missing '* Posts' section"
"Add a '* Posts' headline to define where your posts start"))
;; Check for blank line after "* Posts"
(save-excursion
(goto-char (point-min))
(when (re-search-forward "^\\* Posts\n\n" nil t)
(org-social-validator--error
(line-number-at-pos (match-beginning 0))
1
"Blank line after '* Posts' section"
"Remove the blank line. Posts should start immediately with '**' on the next line")))
(when posts-start
(save-excursion
(goto-char posts-start)
;; Find all level-2 headlines using regex (same as parser)
(while (re-search-forward "^\\*\\*\\($\\|[^*]\\)" nil t)
(let ((post-begin (line-beginning-position)))
;; Try to use org-element, but if it fails, still validate the post
(goto-char post-begin)
(let ((element (ignore-errors (org-element-at-point))))
(if (and element
(eq (org-element-type element) 'headline)
(= (org-element-property :level element) 2))
;; Use org-element if available
(push (org-social-validator--parse-post element) posts)
;; Otherwise, validate manually
(push (org-social-validator--parse-post-manually post-begin) posts)))
;; Move to next line to continue search
(forward-line 1)))))
(nreverse posts)))
;;;###autoload
(defun org-social-validator-validate-file (file)
"Validate Org Social FILE.
Returns a plist with :keywords, :posts, and :errors."
(interactive "fOrg Social file: ")
(setq org-social-validator--current-file file)
(setq org-social-validator--errors nil)
(with-temp-buffer
(insert-file-contents file)
(org-mode)
;; Validate encoding
(org-social-validator--validate-encoding)
;; Parse global keywords
(let ((keywords (org-social-validator--parse-global-keywords))
(posts (org-social-validator--parse-posts)))
;; Display errors if any
(when org-social-validator--errors
(org-social-validator--display-errors))
;; Return result
(list :keywords keywords
:posts posts
:errors (reverse org-social-validator--errors)
:valid (null org-social-validator--errors)))))
;;;###autoload
(defun org-social-validator-validate-buffer ()
"Validate current buffer as an Org Social file."
(interactive)
(unless (derived-mode-p 'org-mode)
(user-error "Current buffer is not in org-mode"))
(setq org-social-validator--current-file (or (buffer-file-name) "current buffer"))
(setq org-social-validator--errors nil)
;; Validate encoding
(org-social-validator--validate-encoding)
;; Parse and validate
(let ((keywords (org-social-validator--parse-global-keywords))
(posts (org-social-validator--parse-posts)))
(if org-social-validator--errors
(org-social-validator--display-errors)
(message "✓ File is valid! Found %d keywords and %d posts."
(length keywords)
(length posts)))))
(defun org-social-validator-validate-silently ()
"Validate current buffer silently without displaying errors.
Returns t if valid, nil otherwise."
(when (derived-mode-p 'org-mode)
(let ((org-social-validator--current-file (or (buffer-file-name) "current buffer")))
(setq org-social-validator--errors nil)
(org-social-validator--validate-encoding)
(org-social-validator--parse-global-keywords)
(org-social-validator--parse-posts)
(null org-social-validator--errors))))
(defun org-social-validator-validate-and-display ()
"Validate the current Org Social buffer and display warnings if any.
Does not block execution - warnings are informational only."
(when (and (derived-mode-p 'org-mode)
(buffer-file-name))
(setq org-social-validator--current-file (buffer-file-name))
(setq org-social-validator--errors nil)
;; Run validation
(org-social-validator--validate-encoding)
(org-social-validator--parse-global-keywords)
(org-social-validator--parse-posts)
;; Display errors if any (non-blocking)
(if org-social-validator--errors
(org-social-validator--display-errors)
(message "✓ Validation successful - no issues found"))))
(provide 'org-social-validator)
;;; org-social-validator.el ends here