Source file tw_html.ml

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
module Css = Cascade.Css
(* HTML component module implementation with integrated Tailwind CSS *)

(* Helper function to concatenate strings *)
let str = String.concat ""

(* Minimal Htmlit implementation - adapted from Htmlit library Original source:
   https://github.com/dbuenzli/htmlit Copyright (c) 2016 The htmlit programmers
   License: ISC

   This is a minimal subset of Htmlit functionality needed for tw. The
   implementation has been simplified to avoid Format dependency. *)
module El = struct
  type html =
    | Element of string * (string * string) list * html list
    | Text of string
    | Void
    | Raw of string

  let v ~at name children = Element (name, at, children)
  let txt s = Text s
  let void = Void
  let unsafe_raw s = Raw s

  let escape_html s =
    let b = Buffer.create (String.length s) in
    String.iter
      (function
        | '<' -> Buffer.add_string b "&lt;"
        | '>' -> Buffer.add_string b "&gt;"
        | '&' -> Buffer.add_string b "&amp;"
        | '"' -> Buffer.add_string b "&quot;"
        | '\'' -> Buffer.add_string b "&#x27;"
        | c -> Buffer.add_char b c)
      s;
    Buffer.contents b

  let rec to_string ?(doctype = false) = function
    | Text s -> escape_html s
    | Raw s -> s
    | Void -> ""
    | Element (name, attrs, children) ->
        let b = Buffer.create 256 in
        if doctype && name = "html" then Buffer.add_string b "<!DOCTYPE html>\n";
        Buffer.add_char b '<';
        Buffer.add_string b name;
        List.iter
          (fun (k, v) ->
            Buffer.add_char b ' ';
            Buffer.add_string b k;
            Buffer.add_string b "=\"";
            Buffer.add_string b (escape_html v);
            Buffer.add_char b '"')
          attrs;
        if
          children = []
          && List.mem name [ "img"; "br"; "hr"; "input"; "meta"; "link" ]
        then Buffer.add_string b " />"
        else (
          Buffer.add_char b '>';
          List.iter
            (fun child -> Buffer.add_string b (to_string child))
            children;
          Buffer.add_string b "</";
          Buffer.add_string b name;
          Buffer.add_char b '>');
        Buffer.contents b
end

module At = struct
  type t = string * string

  let v name value = (name, value)
  let class' s = ("class", s)
  let id s = ("id", s)
  let title s = ("title", s)
  let lang s = ("lang", s)
  let dir s = ("dir", s)
  let tabindex i = ("tabindex", string_of_int i)
  let contenteditable b = ("contenteditable", if b then "true" else "false")
  let spellcheck s = ("spellcheck", s)
  let type' s = ("type", s)
  let value s = ("value", s)
  let name s = ("name", s)
  let placeholder s = ("placeholder", s)
  let required = ("required", "")
  let disabled = ("disabled", "")
  let checked = ("checked", "")
  let href s = ("href", s)
  let rel s = ("rel", s)
  let src s = ("src", s)
  let alt s = ("alt", s)
  let width i = ("width", string_of_int i)
  let height i = ("height", string_of_int i)
  let charset s = ("charset", s)
  let content s = ("content", s)
  let style s = ("style", s)

  (* Additional attributes from the second At module *)
  let onclick s = v "onclick" s
  let onchange s = v "onchange" s
  let oninput s = v "oninput" s
  let onsubmit s = v "onsubmit" s
  let readonly = v "readonly" ""
  let target s = v "target" s
  let download s = v "download" s
  let loading s = v "loading" s
  let property s = v "property" s
  let datetime s = v "datetime" s
  let srcset s = v "srcset" s
  let sizes s = v "sizes" s
  let title' s = v "title" s
  let loading_lazy = v "loading" "lazy"
  let true' name = v name ""
  let false' name = v name "false"

  (* Void attribute for conditionals *)
  let void = ("", "")
  let if' cond at = if cond then at else void
  let if_some = function Some at -> at | None -> void
  let is_void (name, _) = name = ""
  let to_pair at = at
  let of_pair p = p

  (* Additional HTML5 attributes *)
  let accesskey s = v "accesskey" s
  let action s = v "action" s
  let autocomplete s = v "autocomplete" s
  let autofocus = v "autofocus" ""
  let cols i = v "cols" (string_of_int i)
  let colspan i = v "colspan" (string_of_int i)
  let defer = v "defer" ""
  let draggable b = v "draggable" (if b then "true" else "false")
  let for' s = v "for" s
  let hidden = v "hidden" ""
  let list s = v "list" s
  let method' s = v "method" s
  let media s = v "media" s
  let popover s = v "popover" s
  let popovertarget s = v "popovertarget" s
  let popovertargetaction s = v "popovertargetaction" s
  let rows i = v "rows" (string_of_int i)
  let rowspan i = v "rowspan" (string_of_int i)
  let selected = v "selected" ""
  let wrap s = v "wrap" s

  (* SVG attributes *)
  let fill_rule `evenodd = v "fill-rule" "evenodd"
  let clip_rule `evenodd = v "clip-rule" "evenodd"
  let cx i = v "cx" (string_of_int i)
  let cy i = v "cy" (string_of_int i)
  let r i = v "r" (string_of_int i)
  let view_box s = v "viewBox" s
  let fill s = v "fill" s
  let stroke s = v "stroke" s
  let stroke_width s = v "stroke-width" s
  let stroke_linecap s = v "stroke-linecap" s
  let stroke_linejoin s = v "stroke-linejoin" s
  let x s = v "x" s
  let y s = v "y" s
  let rx s = v "rx" s
  let d s = v "d" s
  let x1 s = v "x1" s
  let y1 s = v "y1" s
  let x2 s = v "x2" s
  let y2 s = v "y2" s
end

type tw = Tw.t

(* Type that combines HTML element with its Tailwind classes *)
type t = { el : El.html; tw : tw list; forms : bool }

(* Attribute type - abstract to prevent direct usage of class' *)
type attr = At.t

(* Aria module *)
module Aria = struct
  let label s = At.v "aria-label" s
  let labelledby s = At.v "aria-labelledby" s
  let describedby s = At.v "aria-describedby" s
  let hidden = At.v "aria-hidden" "true"
  let expanded b = At.v "aria-expanded" (string_of_bool b)
  let current s = At.v "aria-current" s
  let role s = At.v "role" s
end

(* Internal helper to convert to El.html *)
let to_htmlit t = t.el
let to_tw t = t.tw
let has_forms t = t.forms

(* Text helpers *)
let txt s = { el = El.txt s; tw = []; forms = false }
let txtf segments = txt (str segments)
let raw s = { el = El.unsafe_raw s; tw = []; forms = false }
let rawf segments = raw (str segments)

(* Empty element *)
let empty = { el = El.void; tw = []; forms = false }

(* Build final attribute list from tw styles, raw class strings, and non-class
   attrs *)
let class_atts tw_styles raw_classes other_atts =
  let tw_cls = match tw_styles with [] -> "" | _ -> Tw.to_classes tw_styles in
  let all_cls =
    match (tw_cls, raw_classes) with
    | "", [] -> ""
    | s, [] -> s
    | "", parts -> String.concat " " parts
    | s, parts -> String.concat " " (s :: parts)
  in
  match all_cls with
  | "" -> List.rev other_atts
  | cls -> At.class' cls :: List.rev other_atts

(* Parse a class string, returning (recognized_tw, raw_strings) *)
let parse_class_value value =
  let classes =
    String.split_on_char ' ' value |> List.filter (fun s -> s <> "")
  in
  List.fold_left
    (fun (tw_acc, raw_acc) cls ->
      match Tw.of_string cls with
      | Ok t -> (t :: tw_acc, raw_acc)
      | Error _ -> (tw_acc, cls :: raw_acc))
    ([], []) classes

(* Extract class attrs from at, parse recognized Tw classes, keep unrecognized
   as-is. Returns (tw_extras, raw_class_parts, other_atts) *)
let extract_class_attrs atts =
  List.fold_left
    (fun (tw_extra, raw_cls, rest) ((name, value) as att) ->
      if name = "class" then
        let tw_parsed, raw = parse_class_value value in
        (List.rev tw_parsed @ tw_extra, List.rev raw @ raw_cls, rest)
      else (tw_extra, raw_cls, att :: rest))
    ([], [], []) atts

(* Helper to create elements - applies tw classes immediately *)
let el_with_tw ?(forms = false) name ?at ?(tw = []) children =
  let atts = Option.value ~default:[] at in
  let tw_from_at, raw_classes, other_atts = extract_class_attrs atts in
  let all_tw_styles = tw @ tw_from_at in
  let atts_with_tw = class_atts all_tw_styles raw_classes other_atts in
  (* Convert children to Htmlit elements *)
  let child_els = List.map to_htmlit children in
  (* Collect all tw styles from this element and its children *)
  let all_tw = all_tw_styles @ List.concat_map to_tw children in
  (* Propagate forms flag from children or this element *)
  let has_forms = forms || List.exists (fun c -> c.forms) children in
  { el = El.v ~at:atts_with_tw name child_els; tw = all_tw; forms = has_forms }

(* Convert to string *)
let to_string ?(doctype = false) t = El.to_string ~doctype (to_htmlit t)

(* Livereload module *)
module Livereload = struct
  let enabled =
    try Sys.getenv "SITE_LIVERELOAD" = "true" with Not_found -> false

  let endpoint =
    try Sys.getenv "SITE_LIVERELOAD_ENDPOINT"
    with Not_found -> "ws://localhost:8080"

  let script =
    if enabled then
      raw
        (str
           [
             "<script>\n";
             "(function() {\n";
             "  const ws = new WebSocket('";
             endpoint;
             "');\n";
             "  ws.onmessage = (event) => {\n";
             "    if (event.data === 'reload') {\n";
             "      location.reload();\n";
             "    }\n";
             "  };\n";
             "})();\n";
             "</script>";
           ])
    else empty
end

(* HTML Elements with optional Tailwind classes *)
let div ?at ?tw children = el_with_tw "div" ?at ?tw children
let span ?at ?tw children = el_with_tw "span" ?at ?tw children
let p ?at ?tw children = el_with_tw "p" ?at ?tw children
let a ?at ?tw children = el_with_tw "a" ?at ?tw children
let ul ?at ?tw children = el_with_tw "ul" ?at ?tw children
let li ?at ?tw children = el_with_tw "li" ?at ?tw children
let nav ?at ?tw children = el_with_tw "nav" ?at ?tw children
let section ?at ?tw children = el_with_tw "section" ?at ?tw children
let article ?at ?tw children = el_with_tw "article" ?at ?tw children
let header ?at ?tw children = el_with_tw "header" ?at ?tw children
let footer ?at ?tw children = el_with_tw "footer" ?at ?tw children
let h1 ?at ?tw children = el_with_tw "h1" ?at ?tw children
let h2 ?at ?tw children = el_with_tw "h2" ?at ?tw children
let h3 ?at ?tw children = el_with_tw "h3" ?at ?tw children
let h4 ?at ?tw children = el_with_tw "h4" ?at ?tw children
let h5 ?at ?tw children = el_with_tw "h5" ?at ?tw children
let h6 ?at ?tw children = el_with_tw "h6" ?at ?tw children
let script ?at ?tw children = el_with_tw "script" ?at ?tw children
let title ?at ?tw children = el_with_tw "title" ?at ?tw children
let head ?at ?tw children = el_with_tw "head" ?at ?tw children
let body ?at ?tw children = el_with_tw "body" ?at ?tw children
let root ?at ?tw children = el_with_tw "html" ?at ?tw children
let option ?at ?tw children = el_with_tw "option" ?at ?tw children
let select ?at ?tw children = el_with_tw ~forms:true "select" ?at ?tw children
let main ?at ?tw children = el_with_tw "main" ?at ?tw children
let aside ?at ?tw children = el_with_tw "aside" ?at ?tw children
let time ?at ?tw children = el_with_tw "time" ?at ?tw children
let dialog ?at ?tw children = el_with_tw "dialog" ?at ?tw children
let data ?at ?tw children = el_with_tw "data" ?at ?tw children
let picture ?at ?tw children = el_with_tw "picture" ?at ?tw children
let slot ?at ?tw children = el_with_tw "slot" ?at ?tw children
let template ?at ?tw children = el_with_tw "template" ?at ?tw children

(* Void elements *)
let void_el ?(forms = false) name ?at ?(tw = []) () =
  let atts = Option.value ~default:[] at in
  let tw_from_at, raw_classes, other_atts = extract_class_attrs atts in
  let all_tw = tw @ tw_from_at in
  let atts_with_tw = class_atts all_tw raw_classes other_atts in
  { el = El.v ~at:atts_with_tw name []; tw = all_tw; forms }

let img ?at ?tw () = void_el "img" ?at ?tw ()
let meta ?at ?tw () = void_el "meta" ?at ?tw ()
let link ?at ?tw () = void_el "link" ?at ?tw ()

(* The <style> element holds raw CSS. Its content is emitted unescaped because
   <style> is a raw-text element: escaping would turn selectors such as [ol>li]
   into [ol&gt;li] and corrupt the stylesheet. *)
let style ?at ?(tw = []) css =
  let atts = Option.value ~default:[] at in
  let tw_from_at, raw_classes, other_atts = extract_class_attrs atts in
  let all_tw = tw @ tw_from_at in
  let atts_with_tw = class_atts all_tw raw_classes other_atts in
  {
    el = El.v ~at:atts_with_tw "style" [ El.unsafe_raw css ];
    tw = all_tw;
    forms = false;
  }

(* Void is now an alias for empty *)
let void = empty

(* Forms *)
let form ?at ?tw children = el_with_tw "form" ?at ?tw children
let input ?at ?tw () = void_el ~forms:true "input" ?at ?tw ()

let textarea ?at ?tw children =
  el_with_tw ~forms:true "textarea" ?at ?tw children

let button ?at ?tw children = el_with_tw "button" ?at ?tw children
let label ?at ?tw children = el_with_tw "label" ?at ?tw children
let fieldset ?at ?tw children = el_with_tw "fieldset" ?at ?tw children
let legend ?at ?tw children = el_with_tw "legend" ?at ?tw children

(* Interactive elements *)
let details ?at ?tw children = el_with_tw "details" ?at ?tw children
let summary ?at ?tw children = el_with_tw "summary" ?at ?tw children

(* Text content *)
let pre ?at ?tw children = el_with_tw "pre" ?at ?tw children
let code ?at ?tw children = el_with_tw "code" ?at ?tw children
let kbd ?at ?tw children = el_with_tw "kbd" ?at ?tw children
let em ?at ?tw children = el_with_tw "em" ?at ?tw children
let strong ?at ?tw children = el_with_tw "strong" ?at ?tw children
let small ?at ?tw children = el_with_tw "small" ?at ?tw children
let mark ?at ?tw children = el_with_tw "mark" ?at ?tw children

(* Breaks *)
let br ?at ?tw () = void_el "br" ?at ?tw ()
let hr ?at ?tw () = void_el "hr" ?at ?tw ()

(* Tables *)
let table ?at ?tw children = el_with_tw "table" ?at ?tw children
let thead ?at ?tw children = el_with_tw "thead" ?at ?tw children
let tbody ?at ?tw children = el_with_tw "tbody" ?at ?tw children
let tfoot ?at ?tw children = el_with_tw "tfoot" ?at ?tw children
let tr ?at ?tw children = el_with_tw "tr" ?at ?tw children
let th ?at ?tw children = el_with_tw "th" ?at ?tw children
let td ?at ?tw children = el_with_tw "td" ?at ?tw children

(* Lists *)
let ol ?at ?tw children = el_with_tw "ol" ?at ?tw children
let dl ?at ?tw children = el_with_tw "dl" ?at ?tw children
let dt ?at ?tw children = el_with_tw "dt" ?at ?tw children
let dd ?at ?tw children = el_with_tw "dd" ?at ?tw children

(* Quotations *)
let blockquote ?at ?tw children = el_with_tw "blockquote" ?at ?tw children

(* Figures *)
let figure ?at ?tw children = el_with_tw "figure" ?at ?tw children
let figcaption ?at ?tw children = el_with_tw "figcaption" ?at ?tw children

(* Media *)
let video ?at ?tw children = el_with_tw "video" ?at ?tw children
let audio ?at ?tw children = el_with_tw "audio" ?at ?tw children
let source ?at ?tw () = void_el "source" ?at ?tw ()

(* Embedded content *)
let canvas ?at ?tw children = el_with_tw "canvas" ?at ?tw children
let iframe ?at ?tw children = el_with_tw "iframe" ?at ?tw children

(* SVG elements *)
let svg ?at ?tw children = el_with_tw "svg" ?at ?tw children
let g ?at ?tw children = el_with_tw "g" ?at ?tw children
let circle ?at ?tw children = el_with_tw "circle" ?at ?tw children
let rect ?at ?tw children = el_with_tw "rect" ?at ?tw children
let path ?at ?tw children = el_with_tw "path" ?at ?tw children
let line ?at ?tw children = el_with_tw "line" ?at ?tw children

(* CSS delivery strategy: link to an external stylesheet (with cache busting) or
   inline the stylesheet directly into the document. *)
type tw_css = Link of string | Inline

(* Type for page generation result *)
type page = { html : string; css : Tw.Css.t; tw_css : tw_css }

let page_impl ~lang ~meta_list ?title_text ~charset ~tw_css ?forms head_content
    body_content =
  (* Build HTML tree with placeholder for CSS link *)
  let meta_charset = meta ~at:[ At.charset charset ] () in
  let meta_tags =
    meta_charset
    :: List.map
         (fun (name, content) ->
           meta ~at:[ At.name name; At.content content ] ())
         meta_list
  in

  (* Create the body and collect all Tw styles *)
  let body_element = body body_content in
  let all_tw = to_tw body_element in

  (* Add styles from head content *)
  let all_tw = all_tw @ List.concat_map to_tw head_content in

  (* The forms plugin base layer follows Tailwind's [\@plugin] model: it is
     emitted only when the plugin is explicitly enabled ([~forms:true]), since a
     page merely containing form controls does not opt into the plugin. When
     [forms] is unset, [Tw.to_css] auto-detects from forms utility usage, the
     same way prose styling is driven by the [prose] utility. *)
  let css_stylesheet =
    match forms with
    | Some forms -> Tw.to_css ~forms all_tw
    | None -> Tw.to_css all_tw
  in
  let css_string = Tw.Css.to_string ~minify:true css_stylesheet in

  (* Build the head CSS node: a cache-busted <link> for an external stylesheet,
     or an inline <style> when the stylesheet is embedded in the document. *)
  let css_head =
    match tw_css with
    | Inline -> style css_string
    | Link file ->
        (* Compute MD5 hash of the CSS content for cache busting *)
        let css_hash =
          let digest = Digest.string css_string in
          (* Use the first 8 hex chars of the MD5 digest *)
          String.sub (Digest.to_hex digest) 0 8
        in
        let css_url_with_hash = String.concat "" [ file; "?v="; css_hash ] in
        link ~at:[ At.rel "stylesheet"; At.href css_url_with_hash ] ()
  in
  let head_children =
    meta_tags
    @ (match title_text with Some t -> [ title [ txt t ] ] | None -> [])
    @ [ css_head ] @ head_content
  in
  let html_tree =
    root ~at:[ At.lang lang ] [ head head_children; body_element ]
  in
  let html_string = to_string ~doctype:true html_tree in
  { html = html_string; css = css_stylesheet; tw_css }

(* Page generation with CSS - use renamed parameters to avoid shadowing *)
let page ?(lang = "en") ?(meta = []) ?title ?(charset = "utf-8")
    ?(tw_css = Link "tw.css") ?forms head_content body_content =
  page_impl ~lang ~meta_list:meta ?title_text:title ~charset ~tw_css ?forms
    head_content body_content

(* Page accessor functions *)
let html page = page.html

let css page =
  match page.tw_css with
  | Link file -> (Some file, page.css)
  | Inline -> (None, page.css)

(* Pretty printing *)
let pp t =
  let tw_classes = Tw.to_classes t.tw in
  let el_str = El.to_string ~doctype:false t.el in
  if tw_classes = "" then el_str
  else
    String.concat ""
      [ "<element with classes=\""; tw_classes; "\">"; el_str; "</element>" ]