p2 2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 37c651c3157074dde83015d5e4e6db8a44ef90b12a8dd890a585539e3eb80b4d
4
+ data.tar.gz: 384d6a649bc089c24d578185f75a7e5a09b22825d70f1367e55b0e8a7f0e3cd8
5
+ SHA512:
6
+ metadata.gz: 6565857078969d64aced3c864d295ca9d4627b08bff842eda2578d8759022ac78c3553b3feef99550efee9498a5f0a943ffae2ca12f5dd001162165101bad557
7
+ data.tar.gz: d0b758535f27c4ec60dac2649d79504c01fcc3e021e318259ea475215cd3d62efec1e360a9529c2bdc6bc1068ed31c44d6339fd44e99aae1bcb4bd8eb97983e8
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ ## 2.0 2025-08-07
2
+
3
+ - Passes all HTML, compilation tests from Papercraft
4
+ - Automatic compilation
5
+ - Plain procs/lambdas as templates
6
+ - Remove everything not having to do with HTML
7
+ - P2: compiled functional templates - they're super fast!
data/README.md ADDED
@@ -0,0 +1,523 @@
1
+ <h1 align="center">
2
+ <img src="p2.png">
3
+ <br>
4
+ P2
5
+ </h1>
6
+
7
+ <h4 align="center">Composable templating for Ruby</h4>
8
+
9
+ <p align="center">
10
+ <a href="http://rubygems.org/gems/p2">
11
+ <img src="https://badge.fury.io/rb/p2.svg" alt="Ruby gem">
12
+ </a>
13
+ <a href="https://github.com/digital-fabric/p2/actions?query=workflow%3ATests">
14
+ <img src="https://github.com/digital-fabric/p2/workflows/Tests/badge.svg" alt="Tests">
15
+ </a>
16
+ <a href="https://github.com/digital-fabric/p2/blob/master/LICENSE">
17
+ <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License">
18
+ </a>
19
+ </p>
20
+
21
+ <p align="center">
22
+ <a href="https://www.rubydoc.info/gems/p2">API reference</a>
23
+ </p>
24
+
25
+ ## What is P2?
26
+
27
+ P2 is a templating engine for dynamically producing [HTML](#html-templates). P2
28
+ templates are expressed as Ruby procs, leading to easier debugging, better
29
+ protection against HTML/XML injection attacks, and better code reuse.
30
+
31
+ P2 templates can be composed in a variety of ways, facilitating the usage of
32
+ layout templates, and enabling a component-oriented approach to building complex
33
+ web interfaces.
34
+
35
+ In P2, dynamic data is passed explicitly to the template as block arguments,
36
+ making the data flow easy to follow and understand. P2 also lets developers
37
+ create derivative templates using full or partial parameter application.
38
+
39
+ P2 includes built-in support for rendering Markdown (using
40
+ [Kramdown](https://github.com/gettalong/kramdown/)).
41
+
42
+ P2 automatically escapes all text emitted in templates according to the template
43
+ type. For more information see the section on [escaping
44
+ content](#escaping-content).
45
+
46
+ ```ruby
47
+ require 'p2'
48
+
49
+ page = ->(**props) {
50
+ html {
51
+ head { title 'My Title' }
52
+ body { yield **props }
53
+ }
54
+ }
55
+ page.render {
56
+ p 'foo'
57
+ }
58
+ #=> "<html><head><title>Title</title></head><body><p>foo</p></body></html>"
59
+
60
+ hello_page = page.apply ->(name:, **) {
61
+ h1 "Hello, #{name}!"
62
+ }
63
+ hello.render(name: 'world')
64
+ #=> "<html><head><title>Title</title></head><body><h1>Hello, world!</h1></body></html>"
65
+ ```
66
+
67
+ ## Table of Content
68
+
69
+ - [Installing P2](#installing-p2)
70
+ - [Basic Usage](#basic-usage)
71
+ - [Adding Tags](#adding-tags)
72
+ - [Tag and Attribute Formatting](#tag-and-attribute-formatting)
73
+ - [Escaping Content](#escaping-content)
74
+ - [Template Parameters](#template-parameters)
75
+ - [Template Logic](#template-logic)
76
+ - [Template Blocks](#template-blocks)
77
+ - [Template Composition](#template-composition)
78
+ - [Parameter and Block Application](#parameter-and-block-application)
79
+ - [Higher-Order Templates](#higher-order-templates)
80
+ - [Layout Template Composition](#layout-template-composition)
81
+ - [Emitting Raw HTML](#emitting-raw-html)
82
+ - [Emitting a String with HTML Encoding](#emitting-a-string-with-html-encoding)
83
+ - [Emitting Markdown](#emitting-markdown)
84
+ - [Deferred Evaluation](#deferred-evaluation)
85
+ - [API Reference](#api-reference)
86
+
87
+ ## Installing P2
88
+
89
+ **Note**: P2 requires Ruby version 3.4 or newer.
90
+
91
+ Using bundler:
92
+
93
+ ```ruby
94
+ gem 'p2'
95
+ ```
96
+
97
+ Or manually:
98
+
99
+ ```bash
100
+ $ gem install p2
101
+ ```
102
+
103
+ ## Basic Usage
104
+
105
+ In P2, an HTML template is expressed as a proc:
106
+
107
+ ```ruby
108
+ html = -> {
109
+ div(id: 'greeter') { p 'Hello!' }
110
+ }
111
+ ```
112
+
113
+ Rendering a template is done using `#render`:
114
+
115
+ ```ruby
116
+ require 'p2'
117
+
118
+ html.render #=> "<div id="greeter"><p>Hello!</p></div>"
119
+ ```
120
+
121
+ ## Adding Tags
122
+
123
+ Tags are added using unqualified method calls, and can be nested using blocks:
124
+
125
+ ```ruby
126
+ -> {
127
+ html {
128
+ head {
129
+ title 'page title'
130
+ }
131
+ body {
132
+ article {
133
+ h1 'article title'
134
+ }
135
+ }
136
+ }
137
+ }
138
+ ```
139
+
140
+ Tag methods accept a string argument, a block, or no argument at all:
141
+
142
+ ```ruby
143
+ -> { p 'hello' }.render #=> "<p>hello</p>"
144
+
145
+ -> { p { span '1'; span '2' } }.render #=> "<p><span>1</span><span>2</span></p>"
146
+
147
+ -> { hr() }.render #=> "<hr/>"
148
+ ```
149
+
150
+ Tag methods also accept tag attributes, given as a hash:
151
+
152
+ ```ruby
153
+ -> { img src: '/my.gif' }.render #=> "<img src=\"/my.gif\"/>"
154
+
155
+ -> { p "foobar", class: 'important' }.render #=> "<p class=\"important\">foobar</p>"
156
+ ```
157
+
158
+ A `true` attribute value will emit a valueless attribute. A `nil` or `false`
159
+ attribute value will emit nothing:
160
+
161
+ ```ruby
162
+ -> { button disabled: nil }.render #=> "<button></button>"
163
+ -> { button disabled: true }.render #=> "<button disabled></button>"
164
+ ```
165
+
166
+ An attribute value given as an array will be joined by space characters:
167
+
168
+ ```ruby
169
+ -> { div class: [:foo, :bar] }.render #=> "<div class=\"foo bar\"></div>"
170
+ ```
171
+
172
+ ## Tag and Attribute Formatting
173
+
174
+ P2 does not make any assumption about what tags and attributes you can use. You
175
+ can mix upper and lower case letters, and you can include arbitrary characters
176
+ in tag and attribute names. However, in order to best adhere to the HTML specs
177
+ and common practices, tag names and attributes will be formatted according to
178
+ the following rules, depending on the template type:
179
+
180
+ - HTML: underscores are converted to dashes:
181
+
182
+ ```ruby
183
+ -> {
184
+ foo_bar { p 'Hello', data_name: 'world' }
185
+ }.render #=> '<foo-bar><p data-name="world">Hello</p></foo-bar>'
186
+ ```
187
+
188
+ If you need more precise control over tag names, you can use the `#tag` method,
189
+ which takes the tag name as its first parameter, then the rest of the parameters
190
+ normally used for tags:
191
+
192
+ ```ruby
193
+ -> {
194
+ tag 'cra_zy__:!tag', 'foo'
195
+ }.render #=> '<cra_zy__:!tag>foo</cra_zy__:!tag>'
196
+ ```
197
+
198
+ ## Escaping Content
199
+
200
+ P2 automatically escapes all text content emitted in a template. The specific
201
+ escaping algorithm depends on the template type. For HTML templates, P2 uses
202
+ [escape_utils](https://github.com/brianmario/escape_utils), specifically:
203
+
204
+ - HTML: `escape_utils.escape_html`
205
+
206
+ In order to emit raw HTML, you can use the `#emit` method as [described
207
+ below](#emitting-raw-html).
208
+
209
+ ## Template Parameters
210
+
211
+ In P2, parameters are always passed explicitly. This means that template
212
+ parameters are specified as block parameters, and are passed to the template on
213
+ rendering:
214
+
215
+ ```ruby
216
+ greeting = -> { |name| h1 "Hello, #{name}!" }
217
+ greeting.render('world') #=> "<h1>Hello, world!</h1>"
218
+ ```
219
+
220
+ Templates can also accept named parameters:
221
+
222
+ ```ruby
223
+ greeting = -> { |name:| h1 "Hello, #{name}!" }
224
+ greeting.render(name: 'world') #=> "<h1>Hello, world!</h1>"
225
+ ```
226
+
227
+ ## Template Logic
228
+
229
+ Since P2 templates are just a bunch of Ruby, you can easily embed your view
230
+ logic right in the template:
231
+
232
+ ```ruby
233
+ -> { |user = nil|
234
+ if user
235
+ span "Hello, #{user.name}!"
236
+ else
237
+ span "Hello, guest!"
238
+ end
239
+ }
240
+ ```
241
+
242
+ ## Template Blocks
243
+
244
+ Templates can also accept and render blocks by using `emit_yield`:
245
+
246
+ ```ruby
247
+ page = -> {
248
+ html {
249
+ body { emit_yield }
250
+ }
251
+ }
252
+
253
+ # we pass the inner HTML
254
+ page.render { h1 'hi' }
255
+ ```
256
+
257
+ ## Template Composition
258
+
259
+ P2 makes it easy to compose multiple templates into a whole HTML document. A P2
260
+ template can contain other templates, as the following example shows.
261
+
262
+ ```ruby
263
+ Title = ->(title) { h1 title }
264
+
265
+ Item = ->(id:, text:, checked:) {
266
+ li {
267
+ input name: id, type: 'checkbox', checked: checked
268
+ label text, for: id
269
+ }
270
+ }
271
+
272
+ ItemList = ->(items) {
273
+ ul {
274
+ items.each { |i|
275
+ Item(**i)
276
+ }
277
+ }
278
+ }
279
+
280
+ page = -> { |title, items|
281
+ html5 {
282
+ head { Title(title) }
283
+ body { ItemList(items) }
284
+ }
285
+ }
286
+
287
+ page.render('Hello from composed templates', [
288
+ { id: 1, text: 'foo', checked: false },
289
+ { id: 2, text: 'bar', checked: true }
290
+ ])
291
+ ```
292
+
293
+ In addition to using templates defined as constants, you can also use
294
+ non-constant templates by invoking the `#emit` method:
295
+
296
+ ```ruby
297
+ greeting = -> { span "Hello, world" }
298
+
299
+ -> {
300
+ div {
301
+ emit greeting
302
+ }
303
+ }
304
+ ```
305
+
306
+ ## Parameter and Block Application
307
+
308
+ Parameters and blocks can be applied to a template without it being rendered, by
309
+ using `#apply`. This mechanism is what allows template composition and the
310
+ creation of higher-order templates.
311
+
312
+ The `#apply` method returns a new template which applies the given parameters
313
+ and or block to the original template:
314
+
315
+ ```ruby
316
+ # parameter application
317
+ hello = -> { |name| h1 "Hello, #{name}!" }
318
+ hello_world = hello.apply('world')
319
+ hello_world.render #=> "<h1>Hello, world!</h1>"
320
+
321
+ # block application
322
+ div_wrap = -> { div { emit_yield } }
323
+ wrapped_h1 = div_wrap.apply { h1 'hi' }
324
+ wrapped_h1.render #=> "<div><h1>hi</h1></div>"
325
+
326
+ # wrap a template
327
+ wrapped_hello_world = div_wrap.apply(&hello_world)
328
+ wrapped_hello_world.render #=> "<div><h1>Hello, world!</h1></div>"
329
+ ```
330
+
331
+ ## Higher-Order Templates
332
+
333
+ P2 also lets you create higher-order templates, that is, templates that take
334
+ other templates as parameters, or as blocks. Higher-order templates are handy
335
+ for creating layouts, wrapping templates in arbitrary markup, enhancing
336
+ templates or injecting template parameters.
337
+
338
+ Here is a higher-order template that takes a template as parameter:
339
+
340
+ ```ruby
341
+ div_wrap = -> { |inner| div { emit inner } }
342
+ greeter = -> { h1 'hi' }
343
+ wrapped_greeter = div_wrap.apply(greeter)
344
+ wrapped_greeter.render #=> "<div><h1>hi</h1></div>"
345
+ ```
346
+
347
+ The inner template can also be passed as a block, as shown above:
348
+
349
+ ```ruby
350
+ div_wrap = -> { div { emit_yield } }
351
+ wrapped_greeter = div_wrap.apply { h1 'hi' }
352
+ wrapped_greeter.render #=> "<div><h1>hi</h1></div>"
353
+ ```
354
+
355
+ ## Layout Template Composition
356
+
357
+ One of the principal uses of higher-order templates is the creation of nested
358
+ layouts. Suppose we have a website with a number of different layouts, and we'd
359
+ like to avoid having to repeat the same code in the different layouts. We can do
360
+ this by creating a `default` page template that takes a block, then use `#apply`
361
+ to create the other templates:
362
+
363
+ ```ruby
364
+ default_layout = -> { |**params|
365
+ html5 {
366
+ head {
367
+ title: params[:title]
368
+ }
369
+ body {
370
+ emit_yield(**params)
371
+ }
372
+ }
373
+ }
374
+
375
+ article_layout = default_layout.apply { |title:, body:|
376
+ article {
377
+ h1 title
378
+ emit_markdown body
379
+ }
380
+ }
381
+
382
+ article_layout.render(
383
+ title: 'This is a title',
384
+ body: 'Hello from *markdown body*'
385
+ )
386
+ ```
387
+
388
+ ## Emitting Raw HTML
389
+
390
+ Raw HTML can be emitted using `#emit`:
391
+
392
+ ```ruby
393
+ wrapped = -> { |html| div { emit html } }
394
+ wrapped.render("<h1>hi</h1>") #=> "<div><h1>hi</h1></div>"
395
+ ```
396
+
397
+ ## Emitting a String with HTML Encoding
398
+
399
+ To emit a string with proper HTML encoding, without wrapping it in an HTML
400
+ element, use `#text`:
401
+
402
+ ```ruby
403
+ -> { text 'hi&lo' }.render #=> "hi&amp;lo"
404
+ ```
405
+
406
+ ## Emitting Markdown
407
+
408
+ Markdown is rendered using the
409
+ [Kramdown](https://kramdown.gettalong.org/index.html) gem. To emit Markdown, use
410
+ `#emit_markdown`:
411
+
412
+ ```ruby
413
+ template = -> { |md| div { emit_markdown md } }
414
+ template.render("Here's some *Markdown*") #=> "<div><p>Here's some <em>Markdown</em><p>\n</div>"
415
+ ```
416
+
417
+ [Kramdown
418
+ options](https://kramdown.gettalong.org/options.html#available-options) can be
419
+ specified by adding them to the `#emit_markdown` call:
420
+
421
+ ```ruby
422
+ template = -> { |md| div { emit_markdown md, auto_ids: false } }
423
+ template.render("# title") #=> "<div><h1>title</h1></div>"
424
+ ```
425
+
426
+ You can also use `P2.markdown` directly:
427
+
428
+ ```ruby
429
+ P2.markdown('# title') #=> "<h1>title</h1>"
430
+ ```
431
+
432
+ The default Kramdown options are:
433
+
434
+ ```ruby
435
+ {
436
+ entity_output: :numeric,
437
+ syntax_highlighter: :rouge,
438
+ input: 'GFM',
439
+ hard_wrap: false
440
+ }
441
+ ```
442
+
443
+ The deafult options can be configured by accessing
444
+ `P2.default_kramdown_options`, e.g.:
445
+
446
+ ```ruby
447
+ P2.default_kramdown_options[:auto_ids] = false
448
+ ```
449
+
450
+ ## Deferred Evaluation
451
+
452
+ Deferred evaluation allows deferring the rendering of parts of a template until
453
+ the last moment, thus allowing an inner template to manipulate the state of the
454
+ outer template. To in order to defer a part of a template, use `#defer`, and
455
+ include any markup in the provided block. This technique, in in conjunction with
456
+ holding state in instance variables, is an alternative to passing parameters,
457
+ which can be limiting in some situations.
458
+
459
+ A few use cases for deferred evaulation come to mind:
460
+
461
+ - Setting the page title.
462
+ - Adding a flash message to a page.
463
+ - Using templates that dynamically add static dependencies (JS and CSS) to the
464
+ page.
465
+
466
+ The last use case is particularly interesting. Imagine a `DependencyMananger`
467
+ class that can collect JS and CSS dependencies from the different templates
468
+ integrated into the page, and adds them to the page's `<head>` element:
469
+
470
+ ```ruby
471
+ default_layout = -> { |**args|
472
+ @dependencies = DependencyMananger.new
473
+ head {
474
+ defer { emit @dependencies.head_markup }
475
+ }
476
+ body { emit_yield **args }
477
+ }
478
+
479
+ button = proc { |text, onclick|
480
+ @dependencies.js '/static/js/button.js'
481
+ @dependencies.css '/static/css/button.css'
482
+
483
+ button text, onclick: onclick
484
+ }
485
+
486
+ heading = proc { |text|
487
+ @dependencies.js '/static/js/heading.js'
488
+ @dependencies.css '/static/css/heading.css'
489
+
490
+ h1 text
491
+ }
492
+
493
+ page = default_layout.apply {
494
+ emit heading, "What's your favorite cheese?"
495
+
496
+ emit button, 'Beaufort', 'eat_beaufort()'
497
+ emit button, 'Mont d''or', 'eat_montdor()'
498
+ emit button, 'Époisses', 'eat_epoisses()'
499
+ }
500
+ ```
501
+
502
+ ## HTML Utility methods
503
+
504
+ HTML templates include a few HTML-specific methods to facilitate writing modern
505
+ HTML:
506
+
507
+ - `html5 { ... }` - emits an HTML 5 DOCTYPE (`<!DOCTYPE html>`)
508
+ - `import_map(root_path, root_url)` - emits an import map including all files
509
+ matching `<root_path>/*.js`, based on the given `root_url`
510
+ - `js_module(js)` - emits a `<script type="module">` element
511
+ - `link_stylesheet(href, **attributes)` - emits a `<link rel="stylesheet" ...>`
512
+ element
513
+ - `script(js, **attributes)` - emits an inline `<script>` element
514
+ - `style(css, **attributes)` - emits an inline `<style>` element
515
+ - `versioned_file_href(href, root_path, root_url)` - calculates a versioned href
516
+ for the given file
517
+
518
+ [HTML docs](https://www.rubydoc.info/gems/p2/P2/HTML)
519
+
520
+ ## API Reference
521
+
522
+ The API reference for this library can be found
523
+ [here](https://www.rubydoc.info/gems/p2).
@@ -0,0 +1,547 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'sirop'
5
+ require 'digest/md5'
6
+
7
+ module P2
8
+ class TagNode
9
+ attr_reader :call_node, :location, :tag, :tag_location, :inner_text, :attributes, :block
10
+
11
+ def initialize(call_node, transformer)
12
+ @call_node = call_node
13
+ @location = call_node.location
14
+ @tag = call_node.name
15
+ prepare_block(transformer)
16
+
17
+ args = call_node.arguments&.arguments
18
+ return if !args
19
+
20
+ if @tag == :tag
21
+ @tag = args[0]
22
+ args = args[1..]
23
+ end
24
+
25
+ if args.size == 1 && args.first.is_a?(Prism::KeywordHashNode)
26
+ @inner_text = nil
27
+ @attributes = args.first
28
+ else
29
+ @inner_text = args.first
30
+ @attributes = args[1].is_a?(Prism::KeywordHashNode) ? args[1] : nil
31
+ end
32
+ end
33
+
34
+ def accept(visitor)
35
+ visitor.visit_tag_node(self)
36
+ end
37
+
38
+ def prepare_block(transformer)
39
+ @block = call_node.block
40
+ if @block.is_a?(Prism::BlockNode)
41
+ @block = transformer.visit(@block)
42
+ offset = @location.start_offset
43
+ length = @block.opening_loc.start_offset - offset
44
+ @tag_location = @location.copy(start_offset: offset, length: length)
45
+ else
46
+ @tag_location = @location
47
+ end
48
+ end
49
+ end
50
+
51
+ class EmitNode
52
+ attr_reader :call_node, :location, :block
53
+
54
+ include Prism::DSL
55
+
56
+ def initialize(call_node, transformer)
57
+ @call_node = call_node
58
+ @location = call_node.location
59
+ @transformer = transformer
60
+ @block = call_node.block && transformer.visit(call_node.block)
61
+
62
+ lambda = call_node.arguments && call_node.arguments.arguments[0]
63
+ return unless lambda.is_a?(Prism::LambdaNode)
64
+
65
+ location = lambda.location
66
+ parameters = lambda.parameters
67
+ parameters_location = parameters&.location || location
68
+ params = parameters&.parameters
69
+ lambda = lambda_node(
70
+ location: location,
71
+ parameters: block_parameters_node(
72
+ location: parameters_location,
73
+ parameters: parameters_node(
74
+ location: parameters_location,
75
+ requireds: [
76
+ required_parameter_node(
77
+ location: ad_hoc_string_location('__buffer__'),
78
+ name: :__buffer__
79
+ ),
80
+ *params&.requireds
81
+ ],
82
+ optionals: transform_array(params&.optionals),
83
+ rest: transform(params&.rest),
84
+ posts: transform_array(params&.posts),
85
+ keywords: transform_array(params&.keywords),
86
+ keyword_rest: transform(params&.keyword_rest),
87
+ block: transform(params&.block)
88
+ )
89
+ ),
90
+ body: transformer.visit(lambda.body)
91
+ )
92
+ call_node.arguments.arguments[0] = lambda
93
+ # pp lambda_body: call_node.arguments.arguments[0]
94
+ end
95
+
96
+ def ad_hoc_string_location(str)
97
+ src = source(str)
98
+ Prism::DSL.location(source: src, start_offset: 0, length: str.bytesize)
99
+ end
100
+
101
+ def transform(node)
102
+ node && @transformer.visit(node)
103
+ end
104
+
105
+ def transform_array(array)
106
+ array ? array.map { @transformer.visit(it) } : []
107
+ end
108
+
109
+ def accept(visitor)
110
+ visitor.visit_emit_node(self)
111
+ end
112
+ end
113
+
114
+ class TextNode
115
+ attr_reader :call_node, :location
116
+
117
+ def initialize(call_node, _compiler)
118
+ @call_node = call_node
119
+ @location = call_node.location
120
+ end
121
+
122
+ def accept(visitor)
123
+ visitor.visit_text_node(self)
124
+ end
125
+ end
126
+
127
+ class DeferNode
128
+ attr_reader :call_node, :location, :block
129
+
130
+ def initialize(call_node, compiler)
131
+ @call_node = call_node
132
+ @location = call_node.location
133
+ @block = call_node.block && compiler.visit(call_node.block)
134
+ end
135
+
136
+ def accept(visitor)
137
+ visitor.visit_defer_node(self)
138
+ end
139
+ end
140
+
141
+ class CustomTagNode
142
+ attr_reader :tag, :call_node, :location, :block
143
+
144
+ def initialize(call_node, compiler)
145
+ @call_node = call_node
146
+ @tag = call_node.name
147
+ @location = call_node.location
148
+ @block = call_node.block && compiler.visit(call_node.block)
149
+ end
150
+
151
+ def accept(visitor)
152
+ visitor.visit_custom_tag_node(self)
153
+ end
154
+ end
155
+
156
+ class TagTransformer < Prism::MutationCompiler
157
+ include Prism::DSL
158
+
159
+ def self.transform(ast)
160
+ ast.accept(new)
161
+ end
162
+
163
+ def visit_call_node(node)
164
+ # We're only interested in compiling method calls without a receiver
165
+ return super(node) if node.receiver
166
+
167
+ case node.name
168
+ when :emit_yield
169
+ yield_node(
170
+ location: node.location,
171
+ arguments: node.arguments
172
+ )
173
+ when :raise
174
+ super(node)
175
+ when :emit, :e
176
+ EmitNode.new(node, self)
177
+ when :text
178
+ TextNode.new(node, self)
179
+ when :defer
180
+ DeferNode.new(node, self)
181
+ when :html5, :emit_markdown, :markdown
182
+ CustomTagNode.new(node, self)
183
+ else
184
+ TagNode.new(node, self)
185
+ end
186
+ end
187
+ end
188
+
189
+ class VerbatimSourcifier < Sirop::Sourcifier
190
+ def visit_tag_node(node)
191
+ visit(node.call_node)
192
+ end
193
+ end
194
+
195
+ class TemplateCompiler < Sirop::Sourcifier
196
+ def self.compile_to_code(proc, wrap: true)
197
+ ast = Sirop.to_ast(proc)
198
+
199
+ # adjust ast root if proc is defined with proc {} / lambda {} syntax
200
+ ast = ast.block if ast.is_a?(Prism::CallNode)
201
+
202
+ compiler = new.with_source_map(proc, ast)
203
+ transformed_ast = TagTransformer.transform(ast.body)
204
+ compiler.format_compiled_template(transformed_ast, ast, wrap:, binding: proc.binding)
205
+ [compiler.source_map, compiler.buffer]
206
+ end
207
+
208
+ def self.compile(proc, wrap: true)
209
+ source_map, code = compile_to_code(proc, wrap:)
210
+ eval(code, proc.binding, source_map[:compiled_fn])
211
+ end
212
+
213
+ attr_reader :source_map
214
+
215
+ def initialize(**)
216
+ super(**)
217
+ @pending_html_parts = []
218
+ @html_loc_start = nil
219
+ @html_loc_end = nil
220
+ @yield_used = nil
221
+ end
222
+
223
+ def with_source_map(orig_proc, orig_ast)
224
+ ast_digest = Digest::MD5.hexdigest(orig_ast.inspect)
225
+ @source_map = {
226
+ source_fn: orig_proc.source_location.first,
227
+ compiled_fn: "::#{ast_digest}"
228
+ }
229
+ @source_map_line_ofs = 1
230
+ self
231
+ end
232
+
233
+ def format_compiled_template(ast, orig_ast, wrap:, binding:)
234
+ # generate source code
235
+ @binding = binding
236
+ visit(ast)
237
+ flush_html_parts!(semicolon_prefix: true)
238
+
239
+ source_code = @buffer
240
+ @buffer = +''
241
+ if wrap
242
+ emit("(#{@source_map.inspect}).then { |src_map| ->(__buffer__")
243
+
244
+ params = orig_ast.parameters
245
+ params = params&.parameters
246
+ if params
247
+ emit(', ')
248
+ emit(format_code(params))
249
+ end
250
+
251
+ if @yield_used
252
+ emit(', &__block__')
253
+ end
254
+
255
+ emit(") do\n")
256
+ end
257
+ @buffer << source_code
258
+ emit_postlude
259
+ if wrap
260
+ emit('; __buffer__')
261
+ adjust_whitespace(orig_ast.closing_loc)
262
+ emit(";") if @buffer !~ /\n\s*$/m
263
+ emit("rescue Exception => e; P2.translate_backtrace(e, src_map); raise e; end }")
264
+ end
265
+ @buffer
266
+ end
267
+
268
+ def emit_code(loc, semicolon: false, chomp: false, flush_html: true)
269
+ flush_html_parts! if flush_html
270
+ super(loc, semicolon:, chomp: )
271
+ end
272
+
273
+ def visit_tag_node(node)
274
+ tag = node.tag
275
+ if tag.is_a?(Symbol) && tag =~ /^[A-Z]/
276
+ return visit_const_tag_node(node.call_node)
277
+ end
278
+
279
+ is_void = is_void_element?(tag)
280
+ emit_html(node.tag_location, format_html_tag_open(tag, node.attributes))
281
+ return if is_void
282
+
283
+ case node.block
284
+ when Prism::BlockNode
285
+ visit(node.block.body)
286
+ when Prism::BlockArgumentNode
287
+ flush_html_parts!
288
+ adjust_whitespace(node.block)
289
+ emit("; #{format_code(node.block.expression)}.render_to_buffer(__buffer__)")
290
+ end
291
+
292
+ if node.inner_text
293
+ if is_static_node?(node.inner_text)
294
+ emit_html(node.location, CGI.escape_html(format_literal(node.inner_text)))
295
+ else
296
+ convert_to_s = !is_string_type_node?(node.inner_text)
297
+ if convert_to_s
298
+ emit_html(node.location, "#\{CGI.escape_html((#{format_code(node.inner_text)}).to_s)}")
299
+ else
300
+ emit_html(node.location, "#\{CGI.escape_html(#{format_code(node.inner_text)})}")
301
+ end
302
+ end
303
+ end
304
+ emit_html(node.location, format_html_tag_close(tag))
305
+ end
306
+
307
+ def visit_const_tag_node(node)
308
+ flush_html_parts!
309
+ adjust_whitespace(node.location)
310
+ if node.receiver
311
+ emit(node.receiver.location)
312
+ emit('::')
313
+ end
314
+ emit("; #{node.name}.render_to_buffer(__buffer__")
315
+ if node.arguments
316
+ emit(', ')
317
+ visit(node.arguments)
318
+ end
319
+ emit(');')
320
+ end
321
+
322
+ def visit_emit_node(node)
323
+ args = node.call_node.arguments.arguments
324
+ first_arg = args.first
325
+ if args.length == 1
326
+ if is_static_node?(first_arg)
327
+ emit_html(node.location, format_literal(first_arg))
328
+ elsif first_arg.is_a?(Prism::LambdaNode)
329
+ visit(first_arg.body)
330
+ else
331
+ emit_html(node.location, "#\{P2.render_emit_call(#{format_code(first_arg)})}")
332
+ end
333
+ else
334
+ block_embed = node.block ? "&(->(__buffer__) #{format_code(node.block)}.compiled!)" : nil
335
+ block_embed = ", #{block_embed}" if block_embed && node.call_node.arguments
336
+ emit_html(node.location, "#\{P2.render_emit_call(#{format_code(node.call_node.arguments)}#{block_embed})}")
337
+ end
338
+ end
339
+
340
+ def visit_text_node(node)
341
+ return if !node.call_node.arguments
342
+
343
+ args = node.call_node.arguments.arguments
344
+ first_arg = args.first
345
+ if args.length == 1
346
+ if is_static_node?(first_arg)
347
+ emit_html(node.location, CGI.escape_html(format_literal(first_arg)))
348
+ else
349
+ emit_html(node.location, "#\{CGI.escape_html(#{format_code(first_arg)}.to_s)}")
350
+ end
351
+ else
352
+ raise "Don't know how to compile #{node}"
353
+ end
354
+ end
355
+
356
+ def visit_defer_node(node)
357
+ block = node.block
358
+ return if !block
359
+
360
+ flush_html_parts!
361
+
362
+ if !@defer_mode
363
+ adjust_whitespace(node.call_node.message_loc)
364
+ emit("__orig_buffer__ = __buffer__; __parts__ = __buffer__ = []; ")
365
+ @defer_mode = true
366
+ end
367
+
368
+ adjust_whitespace(block.opening_loc)
369
+ emit("__buffer__ << ->{")
370
+ visit(block.body)
371
+ flush_html_parts!
372
+ adjust_whitespace(block.closing_loc)
373
+ emit("}")
374
+ end
375
+
376
+ def visit_custom_tag_node(node)
377
+ case node.tag
378
+ when :tag
379
+ args = node.call_node.arguments&.arguments
380
+ when :html5
381
+ emit_html(node.location, '<!DOCTYPE html><html>')
382
+ visit(node.block.body) if node.block
383
+ emit_html(node.block.closing_loc, '</html>')
384
+ when :emit_markdown, :markdown
385
+ args = node.call_node.arguments
386
+ return if !args
387
+
388
+ emit_html(node.location, "#\{P2.markdown(#{format_code(args)})}")
389
+ end
390
+ end
391
+
392
+ def visit_yield_node(node)
393
+ adjust_whitespace(node.location)
394
+ flush_html_parts!
395
+ @yield_used = true
396
+ emit("; (__block__ ? __block__.render_to_buffer(__buffer__")
397
+ if node.arguments
398
+ emit(', ')
399
+ visit(node.arguments)
400
+ end
401
+ emit(") : raise(LocalJumpError, 'no block given (yield)'))")
402
+ end
403
+
404
+ private
405
+
406
+ def format_code(node, klass = TemplateCompiler)
407
+ klass.new(minimize_whitespace: true).to_source(node)
408
+ end
409
+
410
+ VOID_TAGS = %w(area base br col embed hr img input link meta param source track wbr)
411
+
412
+ def is_void_element?(tag)
413
+ VOID_TAGS.include?(tag.to_s)
414
+ end
415
+
416
+ def format_html_tag_open(tag, attributes)
417
+ tag = convert_tag(tag)
418
+ if attributes && attributes&.elements.size > 0
419
+ "<#{tag} #{format_html_attributes(attributes)}>"
420
+ else
421
+ "<#{tag}>"
422
+ end
423
+ end
424
+
425
+ def format_html_tag_close(tag)
426
+ tag = convert_tag(tag)
427
+ "</#{tag}>"
428
+ end
429
+
430
+ def convert_tag(tag)
431
+ case tag
432
+ when Prism::SymbolNode, Prism::StringNode
433
+ P2.format_tag(tag.unescaped)
434
+ when Prism::Node
435
+ "#\{P2.format_tag(#{format_code(tag)})}"
436
+ else
437
+ P2.format_tag(tag)
438
+ end
439
+ end
440
+
441
+ def format_literal(node)
442
+ case node
443
+ when Prism::SymbolNode, Prism::StringNode
444
+ node.unescaped
445
+ when Prism::IntegerNode, Prism::FloatNode
446
+ node.value.to_s
447
+ when Prism::InterpolatedStringNode
448
+ format_code(node)[1..-2]
449
+ when Prism::TrueNode
450
+ 'true'
451
+ when Prism::FalseNode
452
+ 'false'
453
+ when Prism::NilNode
454
+ ''
455
+ else
456
+ "#\{#{format_code(node)}}"
457
+ end
458
+ end
459
+
460
+ STATIC_NODE_TYPES = [
461
+ Prism::FalseNode,
462
+ Prism::FloatNode,
463
+ Prism::IntegerNode,
464
+ Prism::NilNode,
465
+ Prism::StringNode,
466
+ Prism::SymbolNode,
467
+ Prism::TrueNode
468
+ ]
469
+
470
+ def is_static_node?(node)
471
+ STATIC_NODE_TYPES.include?(node.class)
472
+ end
473
+
474
+ STRING_TYPE_NODE_TYPES = [
475
+ Prism::StringNode,
476
+ Prism::InterpolatedStringNode
477
+ ]
478
+
479
+ def is_string_type_node?(node)
480
+ STRING_TYPE_NODE_TYPES.include?(node.class)
481
+ end
482
+
483
+ def format_html_attributes(node)
484
+ elements = node.elements
485
+ dynamic_attributes = elements.any? do
486
+ it.is_a?(Prism::AssocSplatNode) ||
487
+ !is_static_node?(it.key) || !is_static_node?(it.value)
488
+ end
489
+
490
+ return "#\{P2.format_html_attrs(#{format_code(node)})}" if dynamic_attributes
491
+
492
+ parts = elements.map do
493
+ key = it.key
494
+ value = it.value
495
+ case value
496
+ when Prism::TrueNode
497
+ format_literal(key)
498
+ when Prism::FalseNode, Prism::NilNode
499
+ nil
500
+ else
501
+ k = format_literal(key)
502
+ if is_static_node?(value)
503
+ value = format_literal(value)
504
+ "#{P2.format_html_attr_key(k)}=\\\"#{value}\\\""
505
+ else
506
+ "#{P2.format_html_attr_key(k)}=\\\"#\{#{format_code(value)}}\\\""
507
+ end
508
+ end
509
+ end
510
+
511
+ parts.compact.join(' ')
512
+ end
513
+
514
+ def emit_html(loc, str)
515
+ @html_loc_start ||= loc
516
+ @html_loc_end = loc
517
+ @pending_html_parts << str
518
+ end
519
+
520
+ def flush_html_parts!(semicolon_prefix: true)
521
+ return if @pending_html_parts.empty?
522
+
523
+ adjust_whitespace(@html_loc_start)
524
+ if semicolon_prefix && @buffer =~ /[^\s]\s*$/m
525
+ emit '; '
526
+ end
527
+
528
+ str = @pending_html_parts.join
529
+ @pending_html_parts.clear
530
+
531
+ @last_loc = @html_loc_end
532
+ @last_loc_start = loc_start(@html_loc_end)
533
+ @last_loc_end = loc_end(@html_loc_end)
534
+
535
+ @html_loc_start = nil
536
+ @html_loc_end = nil
537
+
538
+ emit "__buffer__ << \"#{str}\""
539
+ end
540
+
541
+ def emit_postlude
542
+ return if !@defer_mode
543
+
544
+ emit("; __buffer__ = __orig_buffer__; __parts__.each { it.is_a?(Proc) ? it.() : (__buffer__ << it) }")
545
+ end
546
+ end
547
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './compiler'
4
+
5
+ # Extensions to the Proc class
6
+ class ::Proc
7
+ def compiled_code
8
+ P2::TemplateCompiler.compile_to_code(self)
9
+ end
10
+
11
+ def compiled?
12
+ @is_compiled
13
+ end
14
+
15
+ def compiled!
16
+ @is_compiled = true
17
+ self
18
+ end
19
+
20
+ def compiled_proc
21
+ @compiled_proc ||= @is_compiled ? self : compile
22
+ end
23
+
24
+ def compile
25
+ P2.compile(self).compiled!
26
+ rescue Sirop::Error
27
+ uncompiled_renderer
28
+ end
29
+
30
+ def render(*a, **b, &c)
31
+ compiled_proc.(+'', *a, **b, &c)
32
+ end
33
+
34
+ def render_to_buffer(buf, *a, **b, &c)
35
+ compiled_proc.(buf, *a, **b, &c)
36
+ end
37
+
38
+ def uncompiled_renderer
39
+ ->(__buffer__, *a, **b, &c) {
40
+ P2::UncompiledProcWrapper.new(self).call(__buffer__, *a, **b, &c)
41
+ __buffer__
42
+ }.compiled!
43
+ end
44
+
45
+ def apply(*a, **b, &c)
46
+ compiled = compiled_proc
47
+ c_compiled = c&.compiled_proc
48
+
49
+ ->(__buffer__, *x, **y, &z) {
50
+ c_proc = c_compiled && ->(__buffer__, *d, **e) {
51
+ c_compiled.(__buffer__, *a, *d, **b, **e, &z)
52
+ }.compiled!
53
+
54
+ compiled.(__buffer__, *a, *x, **b, **y, &c_proc)
55
+ }.compiled!
56
+ end
57
+ end
58
+
59
+ module P2
60
+ class UncompiledProcWrapper
61
+ def initialize(proc)
62
+ @proc = proc
63
+ end
64
+
65
+ def call(buffer, *a, **b)
66
+ @buffer = buffer
67
+ instance_exec(*a, **b, &@proc)
68
+ end
69
+
70
+ def method_missing(sym, *a, **b, &c)
71
+ tag(sym, *a, **b, &c)
72
+ end
73
+
74
+ def p(*a, **b, &c)
75
+ tag(:p, *a, **b, &c)
76
+ end
77
+
78
+ def tag(sym, *a, **b, &block)
79
+ @buffer << "<#{sym}>"
80
+ if block
81
+ instance_eval(&block)
82
+ end
83
+ @buffer << "</#{sym}>"
84
+ end
85
+ end
86
+ end
data/lib/p2/version.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module P2
4
+ VERSION = '2.0'
5
+ end
data/lib/p2.rb ADDED
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'p2/compiler'
4
+ require_relative 'p2/proc_ext'
5
+
6
+ # P2 is a composable templating library
7
+ module P2
8
+ # Exception class used to signal templating-related errors
9
+ class Error < RuntimeError; end
10
+
11
+ class << self
12
+ def compile(proc)
13
+ P2::TemplateCompiler.compile(proc)
14
+ end
15
+
16
+ def format_tag(tag)
17
+ tag.to_s.gsub('_', '-')
18
+ end
19
+
20
+ def format_html_attr_key(tag)
21
+ tag.to_s.tr('_', '-')
22
+ end
23
+
24
+ def format_html_attrs(attrs)
25
+ attrs.each_with_object(+'') do |(k, v), html|
26
+ case v
27
+ when nil, false
28
+ when true
29
+ html << ' ' if !html.empty?
30
+ html << format_html_attr_key(k)
31
+ else
32
+ html << ' ' if !html.empty?
33
+ v = v.join(' ') if v.is_a?(Array)
34
+ html << "#{format_html_attr_key(k)}=\"#{v}\""
35
+ end
36
+ end
37
+ end
38
+
39
+ def render_emit_call(o, *a, **b, &block)
40
+ case o
41
+ when nil
42
+ # do nothing
43
+ when ::Proc
44
+ o.render(*a, **b, &block)
45
+ else
46
+ o.to_s
47
+ end
48
+ end
49
+
50
+ def translate_backtrace(e, source_map)
51
+ re = /^(#{source_map[:compiled_fn]}\:(\d+))/
52
+ source_fn = source_map[:source_fn]
53
+ backtrace = e.backtrace.map {
54
+ if (m = it.match(re))
55
+ line = m[2].to_i
56
+ source_line = source_map[line] || "?(#{line})"
57
+ it.sub(m[1], "#{source_fn}:#{source_line}")
58
+ else
59
+ it
60
+ end
61
+ }
62
+ e.set_backtrace(backtrace)
63
+ end
64
+
65
+ # Renders Markdown into HTML. The `opts` argument will be merged with the
66
+ # default Kramdown options in order to change the rendering behaviour.
67
+ #
68
+ # @param markdown [String] Markdown
69
+ # @param opts [Hash] Kramdown option overrides
70
+ # @return [String] HTML
71
+ def markdown(markdown, **opts)
72
+ # require relevant deps on use
73
+ require 'kramdown'
74
+ require 'rouge'
75
+ require 'kramdown-parser-gfm'
76
+
77
+ opts = default_kramdown_options.merge(opts)
78
+ Kramdown::Document.new(markdown, **opts).to_html
79
+ end
80
+
81
+ # Returns the default Kramdown options used for rendering Markdown.
82
+ #
83
+ # @return [Hash] Kramdown options
84
+ def default_kramdown_options
85
+ @default_kramdown_options ||= {
86
+ entity_output: :numeric,
87
+ syntax_highlighter: :rouge,
88
+ input: 'GFM',
89
+ hard_wrap: false
90
+ }
91
+ end
92
+
93
+ # Sets the default Kramdown options used for rendering Markdown.
94
+ #
95
+ # @param opts [Hash] Kramdown options
96
+ # @return [void]
97
+ def default_kramdown_options=(opts)
98
+ @default_kramdown_options = opts
99
+ end
100
+ end
101
+ end
data/p2.png ADDED
Binary file
metadata ADDED
@@ -0,0 +1,139 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: p2
3
+ version: !ruby/object:Gem::Version
4
+ version: '2.0'
5
+ platform: ruby
6
+ authors:
7
+ - Sharon Rosner
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: sirop
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.8.1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.8.1
26
+ - !ruby/object:Gem::Dependency
27
+ name: kramdown
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 2.5.1
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 2.5.1
40
+ - !ruby/object:Gem::Dependency
41
+ name: rouge
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 4.5.1
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: 4.5.1
54
+ - !ruby/object:Gem::Dependency
55
+ name: kramdown-parser-gfm
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: 1.1.0
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: 1.1.0
68
+ - !ruby/object:Gem::Dependency
69
+ name: minitest
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: 5.25.4
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: 5.25.4
82
+ - !ruby/object:Gem::Dependency
83
+ name: benchmark-ips
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: 2.7.2
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: 2.7.2
96
+ email: sharon@noteflakes.com
97
+ executables: []
98
+ extensions: []
99
+ extra_rdoc_files:
100
+ - README.md
101
+ - p2.png
102
+ files:
103
+ - CHANGELOG.md
104
+ - README.md
105
+ - lib/p2.rb
106
+ - lib/p2/compiler.rb
107
+ - lib/p2/proc_ext.rb
108
+ - lib/p2/version.rb
109
+ - p2.png
110
+ homepage: http://github.com/digital-fabric/p2
111
+ licenses:
112
+ - MIT
113
+ metadata:
114
+ source_code_uri: https://github.com/digital-fabric/p2
115
+ documentation_uri: https://www.rubydoc.info/gems/p2
116
+ homepage_uri: https://github.com/digital-fabric/p2
117
+ changelog_uri: https://github.com/digital-fabric/p2/blob/master/CHANGELOG.md
118
+ rdoc_options:
119
+ - "--title"
120
+ - P2
121
+ - "--main"
122
+ - README.md
123
+ require_paths:
124
+ - lib
125
+ required_ruby_version: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - ">="
128
+ - !ruby/object:Gem::Version
129
+ version: '3.4'
130
+ required_rubygems_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - ">="
133
+ - !ruby/object:Gem::Version
134
+ version: '0'
135
+ requirements: []
136
+ rubygems_version: 3.6.9
137
+ specification_version: 4
138
+ summary: 'P2: component-based HTML templating for Ruby'
139
+ test_files: []