panda-editor 0.2.1 → 0.3.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.
data/docs/FOOTNOTES.md ADDED
@@ -0,0 +1,591 @@
1
+ # Footnotes in Panda Editor
2
+
3
+ Panda Editor provides a powerful footnote system that allows you to add inline citations and references to your content. Footnotes are automatically collected, numbered, and rendered in a collapsible "Sources/References" section at the end of your document.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Overview](#overview)
8
+ - [How It Works](#how-it-works)
9
+ - [JSON Structure](#json-structure)
10
+ - [Field Reference](#field-reference)
11
+ - [Rendered Output](#rendered-output)
12
+ - [Frontend Integration](#frontend-integration)
13
+ - [Advanced Features](#advanced-features)
14
+ - [CSS Styling](#css-styling)
15
+ - [Examples](#examples)
16
+
17
+ ## Overview
18
+
19
+ The footnote system consists of three main components:
20
+
21
+ 1. **Paragraph Block**: Accepts footnote data within paragraph blocks and injects inline markers
22
+ 2. **FootnoteRegistry**: Collects and numbers footnotes across the entire document
23
+ 3. **Renderer**: Coordinates footnote processing and generates the sources section
24
+
25
+ ### Key Features
26
+
27
+ - ✨ **Automatic numbering**: Sequential numbering across the entire document
28
+ - 🔄 **De-duplication**: Same source cited multiple times uses the same footnote number
29
+ - 📍 **Position-based injection**: Place footnote markers at any character position
30
+ - 🎨 **Collapsible UI**: Clean, accessible sources section
31
+ - 🔗 **Bidirectional links**: Navigate between citations and sources
32
+
33
+ ## How It Works
34
+
35
+ ### Architecture
36
+
37
+ ```
38
+ ┌─────────────────────────────────────────────────────────┐
39
+ │ EditorJS JSON │
40
+ │ (Contains blocks with embedded footnote data) │
41
+ └─────────────────────┬───────────────────────────────────┘
42
+
43
+
44
+ ┌─────────────────────────────────────────────────────────┐
45
+ │ Renderer │
46
+ │ - Creates FootnoteRegistry │
47
+ │ - Passes registry to all blocks via options │
48
+ └─────────────────────┬───────────────────────────────────┘
49
+
50
+
51
+ ┌─────────────────────────────────────────────────────────┐
52
+ │ Paragraph Block │
53
+ │ - Processes footnotes array │
54
+ │ - Registers each footnote with registry │
55
+ │ - Injects <sup> markers at specified positions │
56
+ └─────────────────────┬───────────────────────────────────┘
57
+
58
+
59
+ ┌─────────────────────────────────────────────────────────┐
60
+ │ FootnoteRegistry │
61
+ │ - Assigns sequential numbers │
62
+ │ - Tracks footnotes by ID for de-duplication │
63
+ │ - Generates sources section HTML │
64
+ └─────────────────────────────────────────────────────────┘
65
+ ```
66
+
67
+ ### Processing Flow
68
+
69
+ 1. **Initialization**: Renderer creates a `FootnoteRegistry` instance
70
+ 2. **Block Rendering**: Each paragraph block processes its footnotes:
71
+ - Sorts footnotes by position (descending) to avoid position shifts
72
+ - Registers each footnote with the registry
73
+ - Receives a footnote number back
74
+ - Injects superscript marker at the specified position
75
+ 3. **Sources Generation**: After all blocks are rendered, the renderer:
76
+ - Checks if any footnotes were collected
77
+ - Appends the sources section if footnotes exist
78
+
79
+ ## JSON Structure
80
+
81
+ Add footnotes to any paragraph block in your EditorJS JSON:
82
+
83
+ ```json
84
+ {
85
+ "type": "paragraph",
86
+ "data": {
87
+ "text": "Climate change has accelerated significantly since 1980",
88
+ "footnotes": [
89
+ {
90
+ "id": "fn-uuid-123",
91
+ "content": "IPCC. (2023). Climate Change 2023: Synthesis Report.",
92
+ "position": 55
93
+ }
94
+ ]
95
+ }
96
+ }
97
+ ```
98
+
99
+ ### Multiple Footnotes
100
+
101
+ ```json
102
+ {
103
+ "type": "paragraph",
104
+ "data": {
105
+ "text": "Global temperature has risen 1.1°C since pre-industrial times",
106
+ "footnotes": [
107
+ {
108
+ "id": "fn-uuid-456",
109
+ "content": "NASA. (2023). Global Climate Change: Vital Signs.",
110
+ "position": 35
111
+ },
112
+ {
113
+ "id": "fn-uuid-789",
114
+ "content": "NOAA. (2023). State of the Climate Report.",
115
+ "position": 62
116
+ }
117
+ ]
118
+ }
119
+ }
120
+ ```
121
+
122
+ ### Reusing Sources
123
+
124
+ To cite the same source multiple times, use the same `id`:
125
+
126
+ ```json
127
+ {
128
+ "blocks": [
129
+ {
130
+ "type": "paragraph",
131
+ "data": {
132
+ "text": "First mention of the study",
133
+ "footnotes": [{
134
+ "id": "fn-study-2023",
135
+ "content": "Smith et al. (2023). Important Study.",
136
+ "position": 26
137
+ }]
138
+ }
139
+ },
140
+ {
141
+ "type": "paragraph",
142
+ "data": {
143
+ "text": "Second mention of the same study",
144
+ "footnotes": [{
145
+ "id": "fn-study-2023",
146
+ "content": "Smith et al. (2023). Important Study.",
147
+ "position": 33
148
+ }]
149
+ }
150
+ }
151
+ ]
152
+ }
153
+ ```
154
+
155
+ Both paragraphs will reference the same footnote number, and the source will appear only once in the sources section.
156
+
157
+ ## Field Reference
158
+
159
+ ### `id` (required)
160
+
161
+ - **Type**: String
162
+ - **Purpose**: Unique identifier for the footnote
163
+ - **Best Practice**: Use UUIDs or descriptive IDs like `fn-study-name-year`
164
+ - **De-duplication**: Footnotes with the same `id` will share the same number
165
+
166
+ ### `content` (required)
167
+
168
+ - **Type**: String
169
+ - **Purpose**: The citation or reference text
170
+ - **HTML Support**: Basic HTML tags are sanitized and allowed
171
+ - **Best Practice**: Use standard citation formats (APA, MLA, etc.)
172
+
173
+ ### `position` (required)
174
+
175
+ - **Type**: Integer
176
+ - **Purpose**: Character position where the footnote marker should be inserted
177
+ - **Zero-indexed**: Position 0 is before the first character
178
+ - **Validation**: Must be between 0 and text length (inclusive)
179
+
180
+ ## Rendered Output
181
+
182
+ ### Inline Markers
183
+
184
+ ```html
185
+ <p>Climate change has accelerated significantly since 1980<sup id="fnref:1"><a href="#fn:1" class="footnote">1</a></sup></p>
186
+ ```
187
+
188
+ ### Sources Section
189
+
190
+ ```html
191
+ <div class="mx-6 lg:mx-8 mt-4 mb-8">
192
+ <div class="footnotes-section bg-gray-50 rounded-lg overflow-hidden">
193
+ <button class="footnotes-header w-full px-4 py-3 flex items-center justify-between cursor-pointer hover:bg-gray-100 transition-colors"
194
+ data-footnotes-target="toggle"
195
+ data-action="click->footnotes#toggle">
196
+ <h3 class="text-sm font-unbounded font-medium text-gray-900 m-0">Sources/References</h3>
197
+ <svg class="footnotes-chevron w-5 h-5 text-gray-600"
198
+ data-footnotes-target="chevron"
199
+ fill="none"
200
+ stroke="currentColor"
201
+ viewBox="0 0 24 24">
202
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
203
+ </svg>
204
+ </button>
205
+ <div class="footnotes-content" data-footnotes-target="content">
206
+ <ol class="footnotes text-sm text-gray-700 space-y-2 px-4 pb-3">
207
+ <li id="fn:1">
208
+ <p>
209
+ IPCC. (2023). Climate Change 2023: Synthesis Report.
210
+ <a href="#fnref:1" class="footnote-backref">↩</a>
211
+ </p>
212
+ </li>
213
+ </ol>
214
+ </div>
215
+ </div>
216
+ </div>
217
+ ```
218
+
219
+ ## Frontend Integration
220
+
221
+ The sources section includes data attributes designed for use with JavaScript frameworks like Stimulus:
222
+
223
+ ### Data Attributes
224
+
225
+ - `data-footnotes-target="toggle"` - The clickable header button
226
+ - `data-footnotes-target="content"` - The collapsible content section
227
+ - `data-footnotes-target="chevron"` - The chevron icon for rotation animation
228
+
229
+ ### Example Stimulus Controller
230
+
231
+ ```javascript
232
+ import { Controller } from "@hotwired/stimulus"
233
+
234
+ export default class extends Controller {
235
+ static targets = ["toggle", "content", "chevron"]
236
+
237
+ connect() {
238
+ // Start collapsed
239
+ this.contentTarget.classList.add("hidden")
240
+ }
241
+
242
+ toggle(event) {
243
+ event.preventDefault()
244
+
245
+ const isHidden = this.contentTarget.classList.contains("hidden")
246
+
247
+ if (isHidden) {
248
+ this.contentTarget.classList.remove("hidden")
249
+ this.chevronTarget.style.transform = "rotate(180deg)"
250
+ } else {
251
+ this.contentTarget.classList.add("hidden")
252
+ this.chevronTarget.style.transform = "rotate(0deg)"
253
+ }
254
+ }
255
+ }
256
+ ```
257
+
258
+ ### Accessible Mode
259
+
260
+ The footnotes section is designed to be accessible:
261
+
262
+ - Semantic HTML (`<button>`, `<ol>`, proper heading levels)
263
+ - ARIA-ready structure (add `aria-expanded` as needed)
264
+ - Keyboard navigation (links and buttons are focusable)
265
+ - Clear visual hierarchy
266
+
267
+ ## Advanced Features
268
+
269
+ ### Position Calculation
270
+
271
+ When determining where to place a footnote marker, count characters from the beginning of the text:
272
+
273
+ ```javascript
274
+ const text = "Hello world"
275
+ // 0123456789...
276
+
277
+ // To place marker after "Hello"
278
+ position = 5
279
+
280
+ // Result: "Hello<sup>1</sup> world"
281
+ ```
282
+
283
+ ### Handling HTML in Text
284
+
285
+ If your paragraph contains HTML tags, remember that `position` refers to the character position in the **rendered HTML string**:
286
+
287
+ ```javascript
288
+ const text = "Text with <b>bold</b> formatting"
289
+ // Position 10 is after "Text with "
290
+ // Position 13 is inside the <b> tag (after "<b>")
291
+ ```
292
+
293
+ Best practice: Calculate positions based on plain text, not HTML.
294
+
295
+ ### Sorting and Insertion
296
+
297
+ The paragraph block sorts footnotes by position in **descending order** before insertion. This prevents position shifts:
298
+
299
+ ```ruby
300
+ # Without sorting (wrong):
301
+ text = "Hello world"
302
+ insert at position 5: "Hello<sup>1</sup> world" # Now position 11 shifted!
303
+ insert at position 11: Error - position too far
304
+
305
+ # With sorting (correct):
306
+ text = "Hello world"
307
+ insert at position 11: "Hello world<sup>2</sup>"
308
+ insert at position 5: "Hello<sup>1</sup> world<sup>2</sup>"
309
+ ```
310
+
311
+ ### Auto-linking URLs
312
+
313
+ The footnote system can automatically convert plain URLs in footnote content into clickable links.
314
+
315
+ **Enable auto-linking:**
316
+
317
+ ```ruby
318
+ renderer = Panda::Editor::Renderer.new(content, autolink_urls: true)
319
+ output = renderer.render
320
+ ```
321
+
322
+ **How it works:**
323
+
324
+ Plain URLs in footnote content are detected and wrapped in `<a>` tags:
325
+
326
+ ```ruby
327
+ # Input footnote content:
328
+ "Ward, J.H. & Curran, S. (2021). https://doi.org/10.1111/camh.12471"
329
+
330
+ # Rendered output:
331
+ "Ward, J.H. & Curran, S. (2021). <a href=\"https://doi.org/10.1111/camh.12471\" target=\"_blank\" rel=\"noopener noreferrer\">https://doi.org/10.1111/camh.12471</a>"
332
+ ```
333
+
334
+ **Features:**
335
+
336
+ - **Supported protocols**: `http://`, `https://`, `ftp://`, and `www.` (automatically prefixed with `https://`)
337
+ - **Security attributes**: All links include `target="_blank"` and `rel="noopener noreferrer"`
338
+ - **Smart detection**: Won't double-link URLs already in `<a>` tags
339
+ - **Multiple URLs**: Handles multiple URLs in the same footnote
340
+ - **Punctuation handling**: Excludes trailing punctuation (periods, commas, etc.) from URLs
341
+
342
+ **Pattern matching:**
343
+
344
+ The auto-linker uses a sophisticated regex pattern that:
345
+ - Matches complete URLs without breaking on query parameters
346
+ - Avoids linking URLs that are already part of href attributes
347
+ - Handles complex URLs with paths, query strings, and fragments
348
+
349
+ **Example with multiple URLs:**
350
+
351
+ ```ruby
352
+ content = {
353
+ "blocks" => [{
354
+ "type" => "paragraph",
355
+ "data" => {
356
+ "text" => "Research findings",
357
+ "footnotes" => [{
358
+ "id" => "fn-1",
359
+ "content" => "See https://example.com/study and https://doi.org/10.1234/example for details",
360
+ "position" => 17
361
+ }]
362
+ }
363
+ }]
364
+ }
365
+
366
+ renderer = Panda::Editor::Renderer.new(content, autolink_urls: true)
367
+ # Both URLs will be converted to clickable links
368
+ ```
369
+
370
+ **Disabling auto-linking:**
371
+
372
+ By default, auto-linking is disabled. Only enable it when needed:
373
+
374
+ ```ruby
375
+ # Auto-linking disabled (default)
376
+ renderer = Panda::Editor::Renderer.new(content)
377
+
378
+ # Auto-linking enabled
379
+ renderer = Panda::Editor::Renderer.new(content, autolink_urls: true)
380
+ ```
381
+
382
+ **Use in Content Concern:**
383
+
384
+ For applications using the `Panda::Editor::Content` concern, enable auto-linking in the `generate_cached_content` method:
385
+
386
+ ```ruby
387
+ def generate_cached_content
388
+ renderer_options = {autolink_urls: true}
389
+
390
+ if content.is_a?(Hash) && content["blocks"].present?
391
+ self.cached_content = Panda::Editor::Renderer.new(content, renderer_options).render
392
+ end
393
+ end
394
+ ```
395
+
396
+ ## CSS Styling
397
+
398
+ The rendered HTML includes Tailwind CSS classes. You can customize the appearance:
399
+
400
+ ### Default Classes
401
+
402
+ ```html
403
+ <!-- Container -->
404
+ <div class="mx-6 lg:mx-8 mt-4 mb-8">
405
+
406
+ <!-- Section wrapper -->
407
+ <div class="footnotes-section bg-gray-50 rounded-lg overflow-hidden">
408
+
409
+ <!-- Toggle button -->
410
+ <button class="footnotes-header w-full px-4 py-3 flex items-center justify-between cursor-pointer hover:bg-gray-100 transition-colors">
411
+
412
+ <!-- Header text -->
413
+ <h3 class="text-sm font-unbounded font-medium text-gray-900 m-0">
414
+
415
+ <!-- Content area -->
416
+ <div class="footnotes-content">
417
+ <ol class="footnotes text-sm text-gray-700 space-y-2 px-4 pb-3">
418
+ ```
419
+
420
+ ### Custom Styling
421
+
422
+ To customize the appearance, override these classes in your application CSS:
423
+
424
+ ```css
425
+ /* Custom container spacing */
426
+ .footnotes-section {
427
+ @apply my-12;
428
+ }
429
+
430
+ /* Custom header styling */
431
+ .footnotes-header h3 {
432
+ @apply text-lg font-bold;
433
+ }
434
+
435
+ /* Custom footnote list styling */
436
+ .footnotes-content ol {
437
+ @apply text-base leading-relaxed;
438
+ }
439
+
440
+ /* Inline footnote markers */
441
+ sup.footnote a {
442
+ @apply text-blue-600 hover:text-blue-800;
443
+ }
444
+ ```
445
+
446
+ ## Examples
447
+
448
+ ### Academic Citation
449
+
450
+ ```json
451
+ {
452
+ "type": "paragraph",
453
+ "data": {
454
+ "text": "Research shows significant improvements in renewable energy efficiency",
455
+ "footnotes": [{
456
+ "id": "fn-smith-2023",
457
+ "content": "Smith, J., & Jones, M. (2023). Advances in solar panel technology. <em>Journal of Renewable Energy</em>, 45(2), 123-145.",
458
+ "position": 70
459
+ }]
460
+ }
461
+ }
462
+ ```
463
+
464
+ ### Web Resource
465
+
466
+ ```json
467
+ {
468
+ "type": "paragraph",
469
+ "data": {
470
+ "text": "According to NASA, global sea levels have risen 8-9 inches since 1880",
471
+ "footnotes": [{
472
+ "id": "fn-nasa-2023",
473
+ "content": "NASA. (2023). <a href=\"https://climate.nasa.gov/vital-signs/sea-level/\" target=\"_blank\">Sea Level Change Data</a>. Retrieved October 26, 2025.",
474
+ "position": 70
475
+ }]
476
+ }
477
+ }
478
+ ```
479
+
480
+ ### Multiple Citations
481
+
482
+ ```json
483
+ {
484
+ "type": "paragraph",
485
+ "data": {
486
+ "text": "Studies from 2022 and 2023 confirm these findings",
487
+ "footnotes": [
488
+ {
489
+ "id": "fn-study-2022",
490
+ "content": "Johnson, A. (2022). Early intervention strategies.",
491
+ "position": 18
492
+ },
493
+ {
494
+ "id": "fn-study-2023",
495
+ "content": "Williams, B. (2023). Long-term outcomes.",
496
+ "position": 31
497
+ }
498
+ ]
499
+ }
500
+ }
501
+ ```
502
+
503
+ ## Testing
504
+
505
+ The footnote system includes comprehensive test coverage:
506
+
507
+ ### Paragraph Block Tests
508
+
509
+ Located in `spec/lib/panda/editor/blocks/paragraph_spec.rb`:
510
+
511
+ - ✓ Injects footnote markers at correct positions
512
+ - ✓ Registers footnotes with the registry
513
+ - ✓ Handles multiple footnotes in correct order
514
+ - ✓ Returns same number for duplicate IDs
515
+ - ✓ Works without footnotes
516
+
517
+ ### Renderer Tests
518
+
519
+ Located in `spec/lib/panda/editor/renderer_spec.rb`:
520
+
521
+ - ✓ Appends sources section when footnotes exist
522
+ - ✓ Does not append sources section when no footnotes
523
+ - ✓ Handles duplicate footnote IDs correctly
524
+ - ✓ Numbers footnotes sequentially across paragraphs
525
+
526
+ ### Running Tests
527
+
528
+ ```bash
529
+ cd /path/to/panda-editor
530
+ bundle exec rspec spec/lib/panda/editor/blocks/paragraph_spec.rb
531
+ bundle exec rspec spec/lib/panda/editor/renderer_spec.rb
532
+ ```
533
+
534
+ ## Troubleshooting
535
+
536
+ ### Footnote marker not appearing
537
+
538
+ **Problem**: The superscript marker doesn't show up in the rendered HTML.
539
+
540
+ **Solutions**:
541
+ - Check that `position` is within the text length (0 to text.length)
542
+ - Verify the footnote has both `id` and `content` fields
543
+ - Ensure the `FootnoteRegistry` is passed in the options
544
+
545
+ ### Wrong footnote number
546
+
547
+ **Problem**: Footnote shows number 2 when it should be 1.
548
+
549
+ **Solutions**:
550
+ - Check if another footnote is being registered first
551
+ - Verify footnote IDs are unique (unless intentionally reusing)
552
+ - Review the order of blocks in your JSON
553
+
554
+ ### Sources section not appearing
555
+
556
+ **Problem**: Inline markers work but no sources section at bottom.
557
+
558
+ **Solutions**:
559
+ - Verify footnotes are actually being registered (check `FootnoteRegistry#any?`)
560
+ - Ensure the renderer is calling `render_sources_section`
561
+ - Check that blocks are receiving the footnote registry in options
562
+
563
+ ### Position calculation off by one
564
+
565
+ **Problem**: Footnote appears one character before/after expected position.
566
+
567
+ **Solutions**:
568
+ - Remember positions are zero-indexed
569
+ - Position 0 is before the first character
570
+ - Position equal to text.length is after the last character
571
+ - Test with plain text first, then add HTML formatting
572
+
573
+ ## Future Enhancements
574
+
575
+ Potential improvements for future versions:
576
+
577
+ - [ ] Support for footnotes in other block types (headers, quotes, etc.)
578
+ - [ ] Rich text formatting within footnote content
579
+ - [ ] Footnote tooltips on hover
580
+ - [ ] Customizable footnote markers (*, †, ‡, etc.)
581
+ - [ ] Export footnotes to bibliography formats (BibTeX, etc.)
582
+ - [ ] Footnote management UI in EditorJS
583
+ - [ ] Smart position recalculation when text changes
584
+
585
+ ## License
586
+
587
+ This documentation is part of Panda Editor, available under the BSD-3-Clause License.
588
+
589
+ ## Contributing
590
+
591
+ Found a bug or have a suggestion? Please open an issue on GitHub at https://github.com/tastybamboo/panda-editor.
@@ -8,8 +8,46 @@ module Panda
8
8
  content = sanitize(data["text"])
9
9
  return "" if content.blank?
10
10
 
11
+ content = inject_footnotes(content) if data["footnotes"].present?
12
+
11
13
  html_safe("<p>#{content}</p>")
12
14
  end
15
+
16
+ private
17
+
18
+ def inject_footnotes(text)
19
+ return text unless data["footnotes"].is_a?(Array)
20
+
21
+ # Sort footnotes by position in descending order to avoid position shifts
22
+ footnotes = data["footnotes"].sort_by { |fn| -fn["position"].to_i }
23
+
24
+ footnotes.each do |footnote|
25
+ position = footnote["position"].to_i
26
+ # Skip if position is beyond text length
27
+ next if position < 0 || position > text.length
28
+
29
+ # Register footnote with renderer's footnote registry
30
+ footnote_number = register_footnote(footnote)
31
+ next unless footnote_number
32
+
33
+ # Create footnote marker
34
+ marker = "<sup id=\"fnref:#{footnote_number}\"><a href=\"#fn:#{footnote_number}\" class=\"footnote\">#{footnote_number}</a></sup>"
35
+
36
+ # Insert marker at position
37
+ text = text.insert(position, marker)
38
+ end
39
+
40
+ text
41
+ end
42
+
43
+ def register_footnote(footnote)
44
+ return nil unless options[:footnote_registry]
45
+
46
+ options[:footnote_registry].add(
47
+ id: footnote["id"],
48
+ content: footnote["content"]
49
+ )
50
+ end
13
51
  end
14
52
  end
15
53
  end
@@ -36,11 +36,13 @@ module Panda
36
36
  end
37
37
 
38
38
  def generate_cached_content
39
+ renderer_options = {autolink_urls: true}
40
+
39
41
  if content.is_a?(String)
40
42
  begin
41
43
  parsed_content = JSON.parse(content)
42
44
  self.cached_content = if parsed_content.is_a?(Hash) && parsed_content["blocks"].present?
43
- Panda::Editor::Renderer.new(parsed_content).render
45
+ Panda::Editor::Renderer.new(parsed_content, renderer_options).render
44
46
  else
45
47
  content
46
48
  end
@@ -50,7 +52,7 @@ module Panda
50
52
  end
51
53
  elsif content.is_a?(Hash) && content["blocks"].present?
52
54
  # Process EditorJS content
53
- self.cached_content = Panda::Editor::Renderer.new(content).render
55
+ self.cached_content = Panda::Editor::Renderer.new(content, renderer_options).render
54
56
  else
55
57
  # For any other case, store as is
56
58
  self.cached_content = content.to_s
@@ -12,13 +12,9 @@ module Panda
12
12
  g.test_framework :rspec
13
13
  end
14
14
 
15
- # Allow applications to configure editor tools
16
- config.editor_js_tools = []
17
-
18
- # Custom block renderers
19
- config.custom_renderers = {}
20
-
21
15
  initializer "panda_editor.assets" do |app|
16
+ next unless app.config.respond_to?(:assets)
17
+
22
18
  app.config.assets.paths << root.join("app/javascript")
23
19
  app.config.assets.paths << root.join("public")
24
20
  app.config.assets.precompile += %w[panda/editor/*.js panda/editor/*.css]