probatio_diabolica 0.2.0 → 0.3.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 368faf074dc354becfef975422fc3199a1e83293a30bad935ef17696a9b879c9
4
- data.tar.gz: 66b84421f306926f425cdc6d9e5cc4a1ff3b8ed1cd4f6df13e86bd7313796c5d
3
+ metadata.gz: 39336802d7f3c344f21acbf0567ea85a73dfbc68c2d339e7ab5e1e1866fc609e
4
+ data.tar.gz: c4f0fa5209ac0a7437edf9e6cf117b95fe859217b49e172557f05c44dc664307
5
5
  SHA512:
6
- metadata.gz: f9cce79af416cc2b0e216820dd3f07f2d0ba46552db2e2598d9ab2ab83f1492757044717a383256db7b6d14168a3da4d11a0cf89949f6ec1eebe4bf55f366f2c
7
- data.tar.gz: 03e7a8a23b02b473e8bac35c4b4021e729b4e435408bdc69a6143162eebdad8172ec5bb07b83d4471b87464c5f42b1346a112d0e1d7f3af061f916838ea13ea8
6
+ metadata.gz: f78a5ff31c1b5cbee275d596ef74529b0fad41426c4b7ee5303d98d4224cbcf20718485403ec048fc4f26159775c0ae8e33c07eeeb95e6944c2a21cf3feeec26
7
+ data.tar.gz: ffa412e8bc62906e1c9be463a9d48dc9a30bbf4e4fcf458abc1e203026bde0a7e5f60ae0dce03c33cd6375c5402060b80ae1b1cf0b610328b1808a5eb436b8cf
data/README.md CHANGED
@@ -21,7 +21,7 @@ Tests are evaluated with `instance_eval` (not through RSpec).
21
21
 
22
22
  ## Installation
23
23
 
24
- ### From the gem
24
+ ### In the Gemfile
25
25
 
26
26
  ```ruby
27
27
  gem 'probatio_diabolica'
@@ -33,63 +33,6 @@ Then:
33
33
  bundle install
34
34
  ```
35
35
 
36
- In Ruby code, you can load it with:
37
-
38
- ```ruby
39
- require "probatio_diabolica"
40
- ```
41
-
42
- ### From this repository (local development)
43
-
44
- ```bash
45
- bundle install
46
- bundle exec prd examples/basics_spec.rb
47
- ```
48
-
49
- ### Build and install as a gem
50
-
51
- ```bash
52
- # build the package
53
- gem build probatio_diabolica.gemspec
54
-
55
- # install locally from the built gem
56
- gem install ./probatio_diabolica-*.gem
57
- ```
58
-
59
- ## Release workflow
60
-
61
- Use the release helper to bump version, refresh `Gemfile.lock`, create commit/tag, and push:
62
-
63
- ```bash
64
- # explicit version
65
- bin/release --version 0.2.0
66
-
67
- # or env-style
68
- VERSION=0.2.0 bin/release
69
-
70
- # semantic bump from current version
71
- bin/release --bump patch
72
- ```
73
-
74
- Useful options:
75
-
76
- - `--dry-run` preview all actions without modifying files/git
77
- - `--no-push` create commit/tag locally only
78
- - `--skip-tests` skip `bundle exec ruby bin/prd spec --mode synthetic`
79
- - `--allow-dirty` bypass clean-working-tree guard
80
-
81
- After installation, you can run:
82
-
83
- ```bash
84
- prd examples/basics_spec.rb
85
- ```
86
-
87
- If `prd` is not found, add your gem bin directory to `PATH`:
88
-
89
- ```bash
90
- export PATH="$(ruby -e 'print Gem.user_dir')/bin:$PATH"
91
- ```
92
-
93
36
  ## Configuration LLM
94
37
 
95
38
  The runtime automatically loads `prd_helper.rb` if present (or a file passed with `-c`).
@@ -119,6 +62,29 @@ From source checkout (without gem install), this is always valid:
119
62
  bundle exec ruby bin/prd <file_or_directory> [options]
120
63
  ```
121
64
 
65
+ ## MCP server (`run_specs`)
66
+
67
+ A minimal MCP server is available through:
68
+
69
+ ```bash
70
+ bundle exec ruby bin/prd_mcp
71
+ ```
72
+
73
+ It exposes one tool: `run_specs`.
74
+
75
+ Input:
76
+ - `path` (required): file or directory containing specs
77
+ - `config` (optional): same as `-c`
78
+ - `out` (optional): same as `-o`
79
+ - `formatters` (optional): array of `simple|html|json|pdf` (default: `["simple"]`)
80
+ - `mode` (optional): `verbose|synthetic` (default: `synthetic`)
81
+
82
+ Output (`structuredContent`):
83
+ - `ok`, `exit_code`
84
+ - `summary` (`passed`, `failed`, `pending`)
85
+ - `artifacts` (`base_out`, `reports`, `annex_dir`)
86
+ - `logs` (`stdout`, `stderr`)
87
+
122
88
  Options:
123
89
 
124
90
  - `-c, --config FILE` Ruby config file to require
@@ -151,33 +117,36 @@ Examples:
151
117
 
152
118
  ```bash
153
119
  # single file
154
- bundle exec ruby bin/prd examples/basics_spec.rb
120
+ prd examples/basics_spec.rb
155
121
 
156
122
  # all *_spec.rb files in a directory
157
- bundle exec ruby bin/prd examples
123
+ prd examples
158
124
 
159
125
  # HTML report in an existing directory (creates ./tmp/report.html)
160
- bundle exec ruby bin/prd examples/image_spec.rb -t html -o ./tmp/
126
+ prd examples/image_spec.rb -t html -o ./tmp/
161
127
 
162
128
  # multiple reports from one run with shared base name
163
- bundle exec ruby bin/prd examples/basics_spec.rb -t html,json,pdf -o ./tmp/my_report
129
+ prd examples/basics_spec.rb -t html,json,pdf -o ./tmp/my_report
164
130
 
165
131
  # compact synthetic output on console
166
- bundle exec ruby bin/prd examples/basics_spec.rb --mode synthetic
132
+ prd examples/basics_spec.rb --mode synthetic
167
133
  ```
168
134
 
169
135
  ## Available DSL
170
136
 
137
+ It is inspired by RSpec but with a custom runtime and additional features.
138
+
171
139
  ### Structure
172
140
 
173
141
  ```ruby
174
142
  describe 'My domain' do
175
143
  context 'my context' do
176
- let(:value) { 5 }
177
- subject { 'hello' }
144
+ let(:two) { 2 }
145
+ let(:three) { 3 }
146
+ subject { two + three }
178
147
 
179
148
  it 'runs an assertion' do
180
- expect(value).to eq(5)
149
+ expect.to eq(5)
181
150
  end
182
151
 
183
152
  pending 'test to implement later'
@@ -192,6 +161,42 @@ end
192
161
  - `expect { |subject| ... }.to matcher`
193
162
  - `expect.to matcher` (uses `subject`)
194
163
 
164
+ ### Spec best practices for `subject` (PRD reports)
165
+
166
+ When a test defines a `subject`, PRD can surface it more clearly in generated reports.
167
+ For CLI and integration specs, prefer:
168
+
169
+ - grouping with explicit `context`
170
+ - one `subject` per context for the main action
171
+ - assertions written with `expect.to(...)` when the assertion targets `subject`
172
+
173
+ Example:
174
+
175
+ ```ruby
176
+ context 'when CLI receives an unknown formatter type' do
177
+ subject { Open3.capture3('bundle exec ruby bin/prd spec/self_hosted_spec.rb -t unknown') }
178
+
179
+ it 'fails fast on unknown formatter type in CLI' do
180
+ _stdout, stderr, status = subject
181
+
182
+ expect(status.success?).to(be(false))
183
+ expect(stderr).to(includes('Unsupported formatter type: unknown. Supported: simple, html, json, pdf'))
184
+ end
185
+ end
186
+ ```
187
+
188
+ For simple value checks, this pattern keeps specs concise:
189
+
190
+ ```ruby
191
+ context 'with strings' do
192
+ subject { 'probatio diabolica' }
193
+
194
+ it 'matches expected content' do
195
+ expect.to(includes('diabolica'))
196
+ end
197
+ end
198
+ ```
199
+
195
200
  ### Matchers
196
201
 
197
202
  - `eq(expected)` equality with `==`
@@ -205,13 +210,30 @@ end
205
210
 
206
211
  `PrD::Runtime` exposes helpers to test content loaded in Chrome:
207
212
 
213
+ - `page(at:, warmup_time:)` opens a page and returns a `BrowserSession`
208
214
  - `screen(at:, width:, height:, warmup_time:)` captures a PNG and returns a `File`
209
- - `text(at:, css:, warmup_time:)` extracts a CSS node into a `.txt` file and returns a `File`
215
+ - `text(at:, css:, warmup_time:)` extracts a CSS node and returns `PrD::Code` (language: `text`)
210
216
  - `network(at:, warmup_time:)` returns Ferrum network traffic
211
217
  - `network_urls(at:, warmup_time:)` returns traffic URLs
212
218
  - `pdf(at:, warmup_time:)` generates a PDF and returns a `PDF::Reader`
213
219
  - `html(at:, warmup_time:)` returns HTML (`browser.body`)
214
220
 
221
+ `BrowserSession` adds high-level page interactions:
222
+
223
+ - `find(css:/xpath:, wait:, shadow:)`
224
+ - `exists?(css:/xpath:, wait:, shadow:)`
225
+ - `click(css:/xpath:, wait:, shadow:)`
226
+ - `fill(css:/xpath:, with:, clear:, blur:, wait:, shadow:)`
227
+ - `select_option(css:, value:/values:, by:, wait:, shadow:)`
228
+ - `set_files(css:, path:/paths:, wait:, shadow:)` (alias `upload_files`)
229
+ - `navigate(to:, warmup_time:)`
230
+
231
+ About `shadow:`:
232
+
233
+ - `shadow:` is an ordered CSS path used to narrow the scope before the target selector.
234
+ - Each step can be a shadow host or a regular container.
235
+ - If a step has `shadowRoot`, search continues inside it; otherwise search continues inside the matched node.
236
+
215
237
  Prerequisites:
216
238
 
217
239
  - Chrome/Chromium must be installed.
@@ -228,9 +250,39 @@ it 'checks dynamic content loaded in browser' do
228
250
  end
229
251
  ```
230
252
 
253
+ Form interaction and file upload example:
254
+
255
+ ```ruby
256
+ it 'uploads a file in a shadow-dom form' do
257
+ html(at: 'https://example.com/upload', warmup_time: 2) do |page|
258
+ page.click(css: 'button[data-open-upload]')
259
+ page.fill(css: 'input[name="title"]', with: 'Invoice')
260
+ page.set_files(
261
+ css: 'input[type="file"]',
262
+ shadow: ['vax-scanner', '[data-view="upload"]'],
263
+ path: 'examples/random_photo.png'
264
+ )
265
+ end
266
+ end
267
+ ```
268
+
231
269
  ### Source code helper (Prism)
232
270
 
233
271
  `source_code(...)` uses the `prism` gem to parse Ruby source and extract class/method code.
272
+ It returns a `PrD::Code` object:
273
+
274
+ - `source` (`String`)
275
+ - `language` (`String`, default: `ruby`)
276
+
277
+ Example:
278
+
279
+ ```ruby
280
+ let(:code) { source_code(PrD::Matchers::AllMatcher) }
281
+
282
+ it 'uses raw source text' do
283
+ expect(code.source).to(includes('class AllMatcher'))
284
+ end
285
+ ```
234
286
 
235
287
  Prerequisites:
236
288
 
@@ -273,13 +325,16 @@ When you define a `subject`, each formatter tries to render it in the most usefu
273
325
  - for files, prints a textual representation (for example path, file preview for `.txt`)
274
326
  - `HtmlFormatter`:
275
327
  - renders text values directly
328
+ - for `PrD::Code`, renders syntax-highlighted code blocks (Rouge) inside collapsible sections
276
329
  - for image files (`.png`, `.jpg`, `.jpeg`), embeds the image in the report
277
330
  - for PDF subjects (`File` `.pdf` or `PDF::Reader`), embeds the PDF with a `data:application/pdf;base64,...` URI
278
331
  - `PdfFormatter`:
279
332
  - renders text values as report lines
333
+ - for `PrD::Code`, renders language + syntax-highlighted code block (Rouge -> Prawn colors)
280
334
  - for image files (`.png`, `.jpg`, `.jpeg`), inserts the image directly in the PDF report
281
335
  - `JsonFormatter`:
282
336
  - keeps a structured representation for machine processing
337
+ - for `PrD::Code`, emits a structured payload (`type: "code"`, `language`, `source`)
283
338
  - `File` values (images, PDFs, text files, etc.) are embedded as base64 payloads
284
339
  - `PDF::Reader` values are also embedded as base64 (`application/pdf`)
285
340
 
data/bin/prd_mcp ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ begin
4
+ require 'pr_d'
5
+ rescue LoadError
6
+ require_relative '../lib/pr_d'
7
+ end
8
+
9
+ PrD::Mcp::Server.new.run
data/lib/pr_d/code.rb ADDED
@@ -0,0 +1,19 @@
1
+ module PrD
2
+ class Code < SimpleDelegator
3
+ attr_reader :source, :language
4
+
5
+ def initialize(source:, language: 'ruby')
6
+ super(source.to_s)
7
+ @source = source.to_s
8
+ @language = language.to_s
9
+ end
10
+
11
+ def to_s
12
+ @source
13
+ end
14
+
15
+ def include?(value)
16
+ @source.include?(value)
17
+ end
18
+ end
19
+ end
@@ -100,6 +100,10 @@ module PrD
100
100
 
101
101
  value
102
102
  end
103
+
104
+ def code_object?(value)
105
+ defined?(PrD::Code) && value.is_a?(PrD::Code)
106
+ end
103
107
  end
104
108
  end
105
109
  end
@@ -1,5 +1,6 @@
1
1
  require 'cgi'
2
2
  require 'base64'
3
+ require 'rouge'
3
4
 
4
5
  module PrD
5
6
  module Formatters
@@ -9,6 +10,7 @@ module PrD
9
10
  @content = +''
10
11
  @index_entries = []
11
12
  @anchor_counters = Hash.new(0)
13
+ @rouge_formatter = Rouge::Formatters::HTMLLegacy.new(css_class: 'highlight')
12
14
  end
13
15
 
14
16
  def context(message)
@@ -51,20 +53,13 @@ module PrD
51
53
  end
52
54
 
53
55
  def let(value)
56
+ return if synthetic?
57
+ render_value_block('Let', value)
54
58
  end
55
59
 
56
60
  def subject(subject)
57
61
  return if synthetic?
58
- @content << '<div class="subject-block">'
59
- @content << "<p class=\"line\"><strong>Subject:</strong> #{escape(serialize(subject).to_s)}</p>"
60
- if image_file?(subject)
61
- @content << "<img src=\"#{image_data_uri(subject.path)}\" alt=\"Subject image\" class=\"subject-image\" />"
62
- elsif pdf_file?(subject)
63
- @content << "<embed src=\"#{pdf_data_uri(subject.path)}\" type=\"application/pdf\" class=\"subject-pdf\" />"
64
- elsif pdf_reader?(subject)
65
- @content << "<embed src=\"#{pdf_reader_data_uri(subject)}\" type=\"application/pdf\" class=\"subject-pdf\" />"
66
- end
67
- @content << '</div>'
62
+ render_value_block('Subject', subject)
68
63
  end
69
64
 
70
65
  def pending(description = nil)
@@ -85,7 +80,7 @@ module PrD
85
80
 
86
81
  def expect(expectation)
87
82
  return if synthetic?
88
- @content << "<p class=\"line\"><strong>Expect:</strong> #{escape(serialize(expectation).to_s)}</p>"
83
+ render_labeled_value('Expect', expectation)
89
84
  end
90
85
 
91
86
  def to
@@ -102,15 +97,15 @@ module PrD
102
97
  return if synthetic?
103
98
  case matcher
104
99
  when Matchers::EqMatcher
105
- @content << "<p class=\"line\"><strong>Matcher:</strong> Be equal to #{escape(serialize(matcher.expected).to_s)}</p>"
100
+ render_matcher_value('Be equal to', matcher.expected)
106
101
  when Matchers::BeMatcher
107
- @content << "<p class=\"line\"><strong>Matcher:</strong> Be the same object as #{escape(serialize(matcher.expected).to_s)}</p>"
102
+ render_matcher_value('Be the same object as', matcher.expected)
108
103
  when Matchers::IncludesMatcher
109
- @content << "<p class=\"line\"><strong>Matcher:</strong> Include #{escape(serialize(matcher.expected).to_s)}</p>"
104
+ render_matcher_value('Include', matcher.expected)
110
105
  when Matchers::HaveMatcher
111
- @content << "<p class=\"line\"><strong>Matcher:</strong> Have #{escape(serialize(matcher.expected).to_s)}</p>"
106
+ render_matcher_value('Have', matcher.expected)
112
107
  when Matchers::LlmMatcher
113
- @content << "<p class=\"line\"><strong>Matcher:</strong> Satisfy condition #{escape(serialize(matcher.expected).to_s)}</p>"
108
+ render_matcher_value('Satisfy condition', matcher.expected)
114
109
  when Matchers::AllMatcher
115
110
  if sources
116
111
  code_line = matcher.expected.source_location.last.to_i
@@ -156,6 +151,7 @@ module PrD
156
151
  --muted: #6b7280;
157
152
  --line: #e5e7eb;
158
153
  --accent: #0f766e;
154
+ --sidebar-width: 320px;
159
155
  --pass-bg: #ecfdf5;
160
156
  --pass-fg: #166534;
161
157
  --fail-bg: #fef2f2;
@@ -182,12 +178,51 @@ module PrD
182
178
  border-radius: 18px;
183
179
  }
184
180
 
181
+ body.has-index main.container {
182
+ width: min(960px, calc(100% - var(--sidebar-width) - 4rem));
183
+ margin: 1rem 1rem 2rem calc(var(--sidebar-width) + 2rem);
184
+ }
185
+
186
+ body.has-index.index-collapsed main.container {
187
+ width: min(960px, calc(100% - 2rem));
188
+ margin: 1rem auto 2rem;
189
+ }
190
+
191
+ .index-toggle {
192
+ position: fixed;
193
+ top: 0.9rem;
194
+ left: 0.9rem;
195
+ z-index: 1100;
196
+ border: 1px solid #d1d5db;
197
+ border-radius: 999px;
198
+ background: var(--paper);
199
+ color: #0f172a;
200
+ padding: 0.45rem 0.8rem;
201
+ font-size: 0.9rem;
202
+ font-weight: 600;
203
+ cursor: pointer;
204
+ box-shadow: 0 6px 18px rgba(15, 23, 42, 0.12);
205
+ }
206
+
185
207
  .report-index {
208
+ position: fixed;
209
+ top: 3.2rem;
210
+ left: 1rem;
211
+ bottom: 1rem;
212
+ z-index: 1000;
213
+ width: var(--sidebar-width);
186
214
  background: var(--paper);
187
215
  border: 1px solid var(--line);
188
216
  border-radius: 14px;
189
217
  padding: 0.9rem 1rem;
190
- margin-bottom: 1rem;
218
+ overflow-y: auto;
219
+ transition: transform 0.18s ease, opacity 0.18s ease;
220
+ }
221
+
222
+ body.has-index.index-collapsed .report-index {
223
+ transform: translateX(calc(-1 * (var(--sidebar-width) + 1rem)));
224
+ opacity: 0;
225
+ pointer-events: none;
191
226
  }
192
227
 
193
228
  .index-title {
@@ -207,12 +242,16 @@ module PrD
207
242
  padding-left: calc(var(--index-level, 0) * 1rem);
208
243
  }
209
244
 
210
- .index-item a {
245
+ .index-link {
246
+ display: block;
247
+ white-space: nowrap;
248
+ overflow: hidden;
249
+ text-overflow: ellipsis;
211
250
  color: var(--accent);
212
251
  text-decoration: none;
213
252
  }
214
253
 
215
- .index-item a:hover {
254
+ .index-link:hover {
216
255
  text-decoration: underline;
217
256
  }
218
257
 
@@ -314,6 +353,83 @@ module PrD
314
353
  .result.success { color: var(--pass-fg); background: var(--pass-bg); }
315
354
  .result.failure { color: var(--fail-fg); background: var(--fail-bg); }
316
355
  .muted { color: var(--muted); }
356
+
357
+ .code-language {
358
+ color: var(--muted);
359
+ font-size: 0.85rem;
360
+ text-transform: uppercase;
361
+ letter-spacing: 0.05em;
362
+ }
363
+
364
+ .code-block {
365
+ margin: 0.45rem 0 0.7rem;
366
+ border: 1px solid var(--line);
367
+ border-radius: 10px;
368
+ background: #fff;
369
+ }
370
+
371
+ .code-toggle {
372
+ list-style: none;
373
+ cursor: pointer;
374
+ padding: 0.6rem 0.8rem;
375
+ display: flex;
376
+ align-items: center;
377
+ justify-content: space-between;
378
+ gap: 0.8rem;
379
+ font-size: 0.88rem;
380
+ color: var(--text);
381
+ }
382
+
383
+ .code-toggle::-webkit-details-marker {
384
+ display: none;
385
+ }
386
+
387
+ .code-toggle::after {
388
+ content: 'Open';
389
+ color: var(--muted);
390
+ font-size: 0.78rem;
391
+ letter-spacing: 0.03em;
392
+ text-transform: uppercase;
393
+ }
394
+
395
+ .code-block[open] .code-toggle::after {
396
+ content: 'Close';
397
+ }
398
+
399
+ .highlight {
400
+ margin: 0;
401
+ border-top: 1px solid var(--line);
402
+ border-radius: 0 0 10px 10px;
403
+ overflow-x: auto;
404
+ }
405
+
406
+ .highlight pre {
407
+ margin: 0;
408
+ padding: 0.8rem;
409
+ line-height: 1.35;
410
+ }
411
+
412
+ @media (max-width: 960px) {
413
+ :root { --sidebar-width: min(82vw, 320px); }
414
+
415
+ body.has-index main.container,
416
+ body.has-index.index-collapsed main.container {
417
+ width: calc(100% - 1rem);
418
+ margin: 4rem 0.5rem 1rem 0.5rem;
419
+ }
420
+
421
+ .report-index {
422
+ top: 3.5rem;
423
+ left: 0.5rem;
424
+ bottom: 0.5rem;
425
+ }
426
+
427
+ body.has-index.index-collapsed .report-index {
428
+ transform: translateX(calc(-1 * (var(--sidebar-width) + 0.6rem)));
429
+ }
430
+ }
431
+
432
+ #{rouge_theme_css}
317
433
  </style>
318
434
  </head>
319
435
  <body>
@@ -325,28 +441,52 @@ module PrD
325
441
  return '' if @index_entries.empty?
326
442
 
327
443
  index_items = @index_entries.map do |entry|
328
- "<li class=\"index-item\" style=\"--index-level: #{entry[:level]};\"><a href=\"##{entry[:anchor_id]}\">#{escape(index_label(entry))}</a></li>"
444
+ label = escape(index_label(entry))
445
+ "<li class=\"index-item\" style=\"--index-level: #{entry[:level]};\"><a class=\"index-link\" href=\"##{entry[:anchor_id]}\" title=\"#{label}\">#{label}</a></li>"
329
446
  end.join
330
447
 
331
448
  <<~HTML
332
- <nav class="report-index" aria-label="Report index">
449
+ <button type="button" class="index-toggle" aria-expanded="false" aria-controls="report-index">Show index</button>
450
+ <nav id="report-index" class="report-index" aria-label="Report index">
333
451
  <h2 class="index-title">Index</h2>
334
452
  <ul class="index-list">
335
453
  #{index_items}
336
454
  </ul>
337
455
  </nav>
456
+ <script>
457
+ (function() {
458
+ var body = document.body;
459
+ var nav = document.getElementById('report-index');
460
+ var toggle = document.querySelector('.index-toggle');
461
+ if (!body || !nav || !toggle) return;
462
+
463
+ body.classList.add('has-index');
464
+
465
+ var syncToggleLabel = function() {
466
+ var isCollapsed = body.classList.contains('index-collapsed');
467
+ toggle.setAttribute('aria-expanded', String(!isCollapsed));
468
+ toggle.textContent = isCollapsed ? 'Show index' : 'Hide index';
469
+ };
470
+
471
+ toggle.addEventListener('click', function() {
472
+ body.classList.toggle('index-collapsed');
473
+ syncToggleLabel();
474
+ });
475
+
476
+ syncToggleLabel();
477
+ })();
478
+ </script>
338
479
  HTML
339
480
  end
340
481
 
341
482
  def index_label(entry)
342
- prefix =
483
+ marker =
343
484
  case entry[:type]
344
- when :context then 'Context'
345
- when :pending then 'Pending'
346
- else 'Test'
485
+ when :context then '+'
486
+ else '-'
347
487
  end
348
488
 
349
- "#{prefix}: #{entry[:label]}"
489
+ "#{marker} #{entry[:label]}"
350
490
  end
351
491
 
352
492
  def add_index_entry(type:, label:, level:, anchor_id:)
@@ -363,6 +503,72 @@ module PrD
363
503
  "#{prefix}-#{@anchor_counters[prefix]}"
364
504
  end
365
505
 
506
+ def render_labeled_value(label, value)
507
+ if code_object?(value)
508
+ @content << "<p class=\"line\"><strong>#{escape(label)}:</strong></p>"
509
+ @content << render_code_block(value)
510
+ else
511
+ @content << "<p class=\"line\"><strong>#{escape(label)}:</strong> #{escape(serialize(value).to_s)}</p>"
512
+ end
513
+ end
514
+
515
+ def render_matcher_value(label, value)
516
+ if code_object?(value)
517
+ @content << "<p class=\"line\"><strong>Matcher:</strong> #{escape(label)} (#{escape(value.language)})</p>"
518
+ @content << render_code_block(value)
519
+ else
520
+ @content << "<p class=\"line\"><strong>Matcher:</strong> #{escape(label)} #{escape(serialize(value).to_s)}</p>"
521
+ end
522
+ end
523
+
524
+ def render_value_block(label, value)
525
+ @content << '<div class="subject-block">'
526
+ if code_object?(value)
527
+ @content << "<p class=\"line\"><strong>#{escape(label)}:</strong></p>"
528
+ @content << render_code_block(value)
529
+ else
530
+ @content << "<p class=\"line\"><strong>#{escape(label)}:</strong> #{escape(serialize(value).to_s)}</p>"
531
+ end
532
+
533
+ if image_file?(value)
534
+ @content << "<img src=\"#{image_data_uri(value.path)}\" alt=\"#{escape(label)} image\" class=\"subject-image\" />"
535
+ elsif pdf_file?(value)
536
+ @content << "<embed src=\"#{pdf_data_uri(value.path)}\" type=\"application/pdf\" class=\"subject-pdf\" />"
537
+ elsif pdf_reader?(value)
538
+ @content << "<embed src=\"#{pdf_reader_data_uri(value)}\" type=\"application/pdf\" class=\"subject-pdf\" />"
539
+ end
540
+ @content << '</div>'
541
+ end
542
+
543
+ def render_code_block(code)
544
+ source = normalize_text(code.source)
545
+ language = normalize_text(code.language)
546
+ highlighted = highlight_code(source, language)
547
+ <<~HTML
548
+ <details class="code-block">
549
+ <summary class="code-toggle">
550
+ <span class="code-language">#{escape(language)}</span>
551
+ </summary>
552
+ #{highlighted}
553
+ </details>
554
+ HTML
555
+ end
556
+
557
+ def highlight_code(source, language)
558
+ lexer = rouge_lexer_for(source, language)
559
+ @rouge_formatter.format(lexer.lex(source))
560
+ end
561
+
562
+ def rouge_lexer_for(source, language)
563
+ Rouge::Lexer.find_fancy(language, source) || Rouge::Lexers::PlainText
564
+ rescue StandardError
565
+ Rouge::Lexers::PlainText
566
+ end
567
+
568
+ def rouge_theme_css
569
+ Rouge::Themes::Github.render(scope: '.highlight')
570
+ end
571
+
366
572
  def escape(message)
367
573
  CGI.escape_html(normalize_text(message))
368
574
  end