|
| 1 | +# ----------------------------------------------------------------------- |
| 2 | +# This file is part of MoonScript |
| 3 | +# |
| 4 | +# MoonSript is free software: you can redistribute it and/or modify |
| 5 | +# it under the terms of the GNU General Public License as published by |
| 6 | +# the Free Software Foundation, either version 3 of the License, or |
| 7 | +# (at your option) any later version. |
| 8 | +# |
| 9 | +# MoonSript is distributed in the hope that it will be useful, |
| 10 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 11 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 12 | +# GNU General Public License for more details. |
| 13 | +# |
| 14 | +# You should have received a copy of the GNU General Public License |
| 15 | +# along with MoonSript. If not, see <https://www.gnu.org/licenses/>. |
| 16 | +# |
| 17 | +# Copyright (C) 2025 Krisna Pranav, MoonScript Developers |
| 18 | +# ----------------------------------------------------------------------- |
| 19 | + |
| 20 | +require "uri" |
| 21 | + |
| 22 | +module MoonScript |
| 23 | + class VDOMRenderer |
| 24 | + enum Highlight |
| 25 | + MoonOnly |
| 26 | + None |
| 27 | + All |
| 28 | + end |
| 29 | + |
| 30 | + def self.render( |
| 31 | + *, |
| 32 | + highlight : Highlight = Highlight::None, |
| 33 | + replacements : Array(Compiler::Compiled), |
| 34 | + document : Markd::Node, |
| 35 | + separator : String, |
| 36 | + js : Compiler::Js, |
| 37 | + ) : Compiler::Compiled |
| 38 | + render( |
| 39 | + node: self.new.render(document, separator, highlight), |
| 40 | + replacements: replacements, |
| 41 | + separator: separator, |
| 42 | + js: js) |
| 43 | + end |
| 44 | + |
| 45 | + def self.render_html( |
| 46 | + *, |
| 47 | + highlight : Highlight = Highlight::None, |
| 48 | + replacements : Array(String), |
| 49 | + document : Markd::Node, |
| 50 | + separator : String, |
| 51 | + ) |
| 52 | + root = |
| 53 | + self.new.render(document, separator, highlight) |
| 54 | + |
| 55 | + processor = uninitialized Node | String, HtmlBuilder -> Nil |
| 56 | + processor = ->(node : Node | String, builder : HtmlBuilder) do |
| 57 | + case node |
| 58 | + in String |
| 59 | + builder.text node |
| 60 | + in Node |
| 61 | + tag = |
| 62 | + case item = node.tag |
| 63 | + in Compiler::Builtin |
| 64 | + raise "WTF" |
| 65 | + in String |
| 66 | + item |
| 67 | + end |
| 68 | + |
| 69 | + builder.tag(tag, node.attributes) do |
| 70 | + node.children.each do |child| |
| 71 | + processor.call(child, builder) |
| 72 | + end |
| 73 | + end |
| 74 | + end |
| 75 | + end |
| 76 | + |
| 77 | + XML.build_fragment(indent: nil) do |xml| |
| 78 | + builder = HtmlBuilder.new(xml) |
| 79 | + root.children.each do |child| |
| 80 | + processor.call(child, builder) |
| 81 | + end |
| 82 | + end.strip |
| 83 | + end |
| 84 | + |
| 85 | + def self.render( |
| 86 | + replacements : Array(Compiler::Compiled), |
| 87 | + node : Node | String, |
| 88 | + separator : String, |
| 89 | + js : Compiler::Js, |
| 90 | + ) : Compiler::Compiled |
| 91 | + case node |
| 92 | + in String |
| 93 | + if node == separator |
| 94 | + replacements.shift |
| 95 | + else |
| 96 | + js.string(node.gsub("\\", "\\\\")) |
| 97 | + end |
| 98 | + in Node |
| 99 | + attributes = |
| 100 | + node |
| 101 | + .attributes |
| 102 | + .transform_values { |value| [%("#{value}")] of Compiler::Item } |
| 103 | + |
| 104 | + children = |
| 105 | + node.children.map do |item| |
| 106 | + render( |
| 107 | + replacements: replacements, |
| 108 | + separator: separator, |
| 109 | + node: item, |
| 110 | + js: js) |
| 111 | + end |
| 112 | + |
| 113 | + tag = |
| 114 | + case node.tag |
| 115 | + in Compiler::Builtin |
| 116 | + node.tag |
| 117 | + in String |
| 118 | + %('#{node.tag}') |
| 119 | + end |
| 120 | + |
| 121 | + js.call(Compiler::Builtin::CreateElement, [ |
| 122 | + [tag] of Compiler::Item, |
| 123 | + js.object(attributes), |
| 124 | + js.array(children), |
| 125 | + ]) |
| 126 | + end |
| 127 | + end |
| 128 | + |
| 129 | + class Node |
| 130 | + property children : Array(Node | String) |
| 131 | + getter attributes : Hash(String, String) |
| 132 | + getter tag : Compiler::Builtin | String |
| 133 | + |
| 134 | + def initialize( |
| 135 | + @tag : Compiler::Builtin | String, *, |
| 136 | + @attributes = {} of String => String, |
| 137 | + @children = [] of Node | String, |
| 138 | + ) |
| 139 | + end |
| 140 | + end |
| 141 | + |
| 142 | + HEADINGS = %w(h1 h2 h3 h4 h5 h6) |
| 143 | + |
| 144 | + getter stack : Array(Node) = [] of Node |
| 145 | + |
| 146 | + def render( |
| 147 | + document : Markd::Node, |
| 148 | + separator : String, |
| 149 | + highlight : Highlight, |
| 150 | + ) : Node |
| 151 | + walker = |
| 152 | + document.walker |
| 153 | + |
| 154 | + while event = walker.next |
| 155 | + node, entering = event |
| 156 | + |
| 157 | + next if (grand_parent = node.parent?.try &.parent?) && |
| 158 | + node.type == Markd::Node::Type::Paragraph && |
| 159 | + grand_parent.type.list? && |
| 160 | + grand_parent.data["tight"] |
| 161 | + |
| 162 | + if entering |
| 163 | + item = |
| 164 | + case node.type |
| 165 | + in Markd::Node::Type::Document then Node.new(Compiler::Builtin::Fragment) |
| 166 | + in Markd::Node::Type::BlockQuote then Node.new("blockquote") |
| 167 | + in Markd::Node::Type::Strong then Node.new("strong") |
| 168 | + in Markd::Node::Type::Emphasis then Node.new("em") |
| 169 | + in Markd::Node::Type::Item then Node.new("li") |
| 170 | + in Markd::Node::Type::ThematicBreak then Node.new("hr") |
| 171 | + in Markd::Node::Type::LineBreak then Node.new("br") |
| 172 | + in Markd::Node::Type::Paragraph then Node.new("p") |
| 173 | + in Markd::Node::Type::Heading |
| 174 | + Node.new(HEADINGS[node.data["level"].as(Int32) - 1]) |
| 175 | + in Markd::Node::Type::Code |
| 176 | + Node.new("code", children: [ |
| 177 | + node.text.strip, |
| 178 | + ] of Node | String) |
| 179 | + in Markd::Node::Type::Link |
| 180 | + Node.new("a", attributes: { |
| 181 | + "href" => node.data["destination"].as(String), |
| 182 | + "title" => node.data["title"].as(String).presence, |
| 183 | + }.compact) |
| 184 | + in Markd::Node::Type::Image |
| 185 | + Node.new("img", attributes: { |
| 186 | + "src" => node.data["destination"].as(String), |
| 187 | + "alt" => node.first_child.text, |
| 188 | + }) |
| 189 | + in Markd::Node::Type::List |
| 190 | + attributes = |
| 191 | + {} of String => String |
| 192 | + |
| 193 | + if start = node.data["start"].as(Int32) |
| 194 | + attributes["start"] = start.to_s unless start == 1 |
| 195 | + end |
| 196 | + |
| 197 | + Node.new( |
| 198 | + node.data["type"] == "bullet" ? "ul" : "ol", |
| 199 | + attributes: attributes) |
| 200 | + in Markd::Node::Type::CodeBlock |
| 201 | + languages = |
| 202 | + node.fence_language.try(&.split) |
| 203 | + |
| 204 | + language = |
| 205 | + languages.try(&.first?).try(&.strip.presence) |
| 206 | + |
| 207 | + attributes = |
| 208 | + {} of String => String |
| 209 | + |
| 210 | + attributes["class"] = |
| 211 | + "language-#{language}" if language |
| 212 | + |
| 213 | + children = |
| 214 | + if highlight != Highlight::None |
| 215 | + if (highlight == Highlight::MoonOnly && language == "moon") || |
| 216 | + highlight == Highlight::All |
| 217 | + ast = |
| 218 | + Parser.parse_any(node.text.strip, "source.moon") |
| 219 | + |
| 220 | + unless ast.nodes.empty? |
| 221 | + SemanticTokenizer |
| 222 | + .tokenize_with_lines(ast) |
| 223 | + .map do |parts| |
| 224 | + items = |
| 225 | + parts.map do |part| |
| 226 | + case part |
| 227 | + in String |
| 228 | + part |
| 229 | + in Tuple(SemanticTokenizer::TokenType, String) |
| 230 | + Node.new("span", |
| 231 | + attributes: {"class" => part[0].to_s.underscore}, |
| 232 | + children: [part[1]] of Node | String) |
| 233 | + end |
| 234 | + end |
| 235 | + |
| 236 | + Node.new("span", |
| 237 | + attributes: {"class" => "line"}, |
| 238 | + children: items).as(Node | String) |
| 239 | + end |
| 240 | + end |
| 241 | + end || begin |
| 242 | + node.text.strip.split("\n").map do |part| |
| 243 | + Node.new("span", |
| 244 | + attributes: {"class" => "line"}, |
| 245 | + children: [part] of Node | String |
| 246 | + ).as(Node | String) |
| 247 | + end |
| 248 | + end |
| 249 | + else |
| 250 | + [node.text.strip] of Node | String |
| 251 | + end |
| 252 | + |
| 253 | + Node.new("pre", children: [ |
| 254 | + Node.new("code", |
| 255 | + attributes: attributes, |
| 256 | + children: children), |
| 257 | + ] of Node | String) |
| 258 | + in Markd::Node::Type::HTMLInline, |
| 259 | + Markd::Node::Type::HTMLBlock, |
| 260 | + Markd::Node::Type::Text |
| 261 | + next if (parent = node.parent?) && parent.type.image? |
| 262 | + node.text |
| 263 | + in Markd::Node::Type::SoftBreak |
| 264 | + "\n" |
| 265 | + in Markd::Node::Type::CustomInLine, |
| 266 | + Markd::Node::Type::CustomBlock |
| 267 | + end |
| 268 | + |
| 269 | + case item |
| 270 | + when String |
| 271 | + unless item.empty? |
| 272 | + stack.last?.try(&.children.concat(replace(item, separator))) |
| 273 | + end |
| 274 | + when Node |
| 275 | + stack.last?.try(&.children.concat(replace(item, separator))) |
| 276 | + end |
| 277 | + |
| 278 | + case item |
| 279 | + when Node |
| 280 | + if !node.type.in?( |
| 281 | + Markd::Node::Type::ThematicBreak, |
| 282 | + Markd::Node::Type::LineBreak, |
| 283 | + Markd::Node::Type::CodeBlock, |
| 284 | + Markd::Node::Type::Code) |
| 285 | + stack.push(item) |
| 286 | + end |
| 287 | + end |
| 288 | + else |
| 289 | + case node.type |
| 290 | + when Markd::Node::Type::ThematicBreak, |
| 291 | + Markd::Node::Type::BlockQuote, |
| 292 | + Markd::Node::Type::Paragraph, |
| 293 | + Markd::Node::Type::CodeBlock, |
| 294 | + Markd::Node::Type::Emphasis, |
| 295 | + Markd::Node::Type::Document, |
| 296 | + Markd::Node::Type::Heading, |
| 297 | + Markd::Node::Type::Strong, |
| 298 | + Markd::Node::Type::Image, |
| 299 | + Markd::Node::Type::Item, |
| 300 | + Markd::Node::Type::Code, |
| 301 | + Markd::Node::Type::Link, |
| 302 | + Markd::Node::Type::List |
| 303 | + stack.pop if stack.size > 1 |
| 304 | + end |
| 305 | + end |
| 306 | + end |
| 307 | + |
| 308 | + stack.first |
| 309 | + end |
| 310 | + |
| 311 | + def replace(item : String, separator : String) : Array(Node | String) |
| 312 | + if separator.size > 0 && item.includes?(separator) |
| 313 | + item |
| 314 | + .split(separator) |
| 315 | + .intersperse(separator) |
| 316 | + .map { |part| part.as(Node | String) } |
| 317 | + else |
| 318 | + [item] of Node | String |
| 319 | + end |
| 320 | + end |
| 321 | + |
| 322 | + def replace(node : Node, separator : String) : Array(Node | String) |
| 323 | + node.children = |
| 324 | + node.children.flat_map do |item| |
| 325 | + replace(item, separator) |
| 326 | + end |
| 327 | + |
| 328 | + [node] of Node | String |
| 329 | + end |
| 330 | + end |
| 331 | +end |
0 commit comments