inkpen 0.7.1

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.
Files changed (95) hide show
  1. checksums.yaml +7 -0
  2. data/.DS_Store +0 -0
  3. data/.rubocop.yml +8 -0
  4. data/.yardopts +11 -0
  5. data/CLAUDE.md +141 -0
  6. data/README.md +409 -0
  7. data/Rakefile +19 -0
  8. data/app/assets/javascripts/inkpen/controllers/editor_controller.js +2050 -0
  9. data/app/assets/javascripts/inkpen/controllers/sticky_toolbar_controller.js +667 -0
  10. data/app/assets/javascripts/inkpen/controllers/toolbar_controller.js +693 -0
  11. data/app/assets/javascripts/inkpen/export/html.js +637 -0
  12. data/app/assets/javascripts/inkpen/export/index.js +30 -0
  13. data/app/assets/javascripts/inkpen/export/markdown.js +697 -0
  14. data/app/assets/javascripts/inkpen/export/pdf.js +372 -0
  15. data/app/assets/javascripts/inkpen/extensions/advanced_table.js +640 -0
  16. data/app/assets/javascripts/inkpen/extensions/block_commands.js +300 -0
  17. data/app/assets/javascripts/inkpen/extensions/block_gutter.js +338 -0
  18. data/app/assets/javascripts/inkpen/extensions/callout.js +303 -0
  19. data/app/assets/javascripts/inkpen/extensions/columns.js +403 -0
  20. data/app/assets/javascripts/inkpen/extensions/database.js +990 -0
  21. data/app/assets/javascripts/inkpen/extensions/document_section.js +352 -0
  22. data/app/assets/javascripts/inkpen/extensions/drag_handle.js +407 -0
  23. data/app/assets/javascripts/inkpen/extensions/embed.js +629 -0
  24. data/app/assets/javascripts/inkpen/extensions/enhanced_image.js +566 -0
  25. data/app/assets/javascripts/inkpen/extensions/export_commands.js +271 -0
  26. data/app/assets/javascripts/inkpen/extensions/file_attachment.js +593 -0
  27. data/app/assets/javascripts/inkpen/extensions/inkpen_table/index.js +58 -0
  28. data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table.js +638 -0
  29. data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table_cell.js +100 -0
  30. data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table_header.js +100 -0
  31. data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_constants.js +152 -0
  32. data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_helpers.js +254 -0
  33. data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_menu.js +282 -0
  34. data/app/assets/javascripts/inkpen/extensions/preformatted.js +239 -0
  35. data/app/assets/javascripts/inkpen/extensions/section.js +281 -0
  36. data/app/assets/javascripts/inkpen/extensions/section_title.js +126 -0
  37. data/app/assets/javascripts/inkpen/extensions/slash_commands.js +439 -0
  38. data/app/assets/javascripts/inkpen/extensions/table_of_contents.js +474 -0
  39. data/app/assets/javascripts/inkpen/extensions/toggle_block.js +332 -0
  40. data/app/assets/javascripts/inkpen/index.js +87 -0
  41. data/app/assets/stylesheets/inkpen/advanced_table.css +514 -0
  42. data/app/assets/stylesheets/inkpen/animations.css +626 -0
  43. data/app/assets/stylesheets/inkpen/block_gutter.css +265 -0
  44. data/app/assets/stylesheets/inkpen/callout.css +359 -0
  45. data/app/assets/stylesheets/inkpen/columns.css +314 -0
  46. data/app/assets/stylesheets/inkpen/database.css +658 -0
  47. data/app/assets/stylesheets/inkpen/document_section.css +305 -0
  48. data/app/assets/stylesheets/inkpen/drag_drop.css +220 -0
  49. data/app/assets/stylesheets/inkpen/editor.css +652 -0
  50. data/app/assets/stylesheets/inkpen/embed.css +468 -0
  51. data/app/assets/stylesheets/inkpen/enhanced_image.css +453 -0
  52. data/app/assets/stylesheets/inkpen/export.css +499 -0
  53. data/app/assets/stylesheets/inkpen/file_attachment.css +347 -0
  54. data/app/assets/stylesheets/inkpen/footnotes.css +136 -0
  55. data/app/assets/stylesheets/inkpen/inkpen_table.css +608 -0
  56. data/app/assets/stylesheets/inkpen/preformatted.css +215 -0
  57. data/app/assets/stylesheets/inkpen/search_replace.css +58 -0
  58. data/app/assets/stylesheets/inkpen/section.css +236 -0
  59. data/app/assets/stylesheets/inkpen/slash_menu.css +252 -0
  60. data/app/assets/stylesheets/inkpen/sticky_toolbar.css +314 -0
  61. data/app/assets/stylesheets/inkpen/toc.css +386 -0
  62. data/app/assets/stylesheets/inkpen/toggle.css +260 -0
  63. data/app/helpers/inkpen/editor_helper.rb +114 -0
  64. data/app/views/inkpen/_editor.html.erb +139 -0
  65. data/config/importmap.rb +170 -0
  66. data/docs/.DS_Store +0 -0
  67. data/docs/CHANGELOG.md +571 -0
  68. data/docs/FEATURES.md +436 -0
  69. data/docs/ROADMAP.md +3029 -0
  70. data/docs/VISION.md +235 -0
  71. data/docs/extensions/INKPEN_TABLE.md +482 -0
  72. data/docs/thinking/CORRECTED_NO_VUE.md +756 -0
  73. data/docs/thinking/EXECUTIVE_SUMMARY.md +403 -0
  74. data/docs/thinking/INKPEN_CODE_SAMPLES.md +1479 -0
  75. data/docs/thinking/INKPEN_MASTER_GUIDE.md +891 -0
  76. data/docs/thinking/README_START_HERE.md +341 -0
  77. data/lib/inkpen/configuration.rb +175 -0
  78. data/lib/inkpen/editor.rb +204 -0
  79. data/lib/inkpen/engine.rb +32 -0
  80. data/lib/inkpen/extensions/base.rb +109 -0
  81. data/lib/inkpen/extensions/code_block_syntax.rb +177 -0
  82. data/lib/inkpen/extensions/document_section.rb +111 -0
  83. data/lib/inkpen/extensions/forced_document.rb +183 -0
  84. data/lib/inkpen/extensions/mention.rb +155 -0
  85. data/lib/inkpen/extensions/preformatted.rb +111 -0
  86. data/lib/inkpen/extensions/section.rb +139 -0
  87. data/lib/inkpen/extensions/slash_commands.rb +100 -0
  88. data/lib/inkpen/extensions/table.rb +182 -0
  89. data/lib/inkpen/extensions/task_list.rb +145 -0
  90. data/lib/inkpen/sticky_toolbar.rb +157 -0
  91. data/lib/inkpen/toolbar.rb +145 -0
  92. data/lib/inkpen/version.rb +5 -0
  93. data/lib/inkpen.rb +101 -0
  94. data/sig/inkpen.rbs +4 -0
  95. metadata +165 -0
@@ -0,0 +1,1479 @@
1
+ # Inkpen: Code Samples & Implementation Reference
2
+ ## Complete Code Examples for INKPEN_MASTER_GUIDE.md
3
+
4
+ This document contains all detailed code samples referenced in the master guide.
5
+
6
+ ---
7
+
8
+ ## A. Extension Classes
9
+
10
+ ### A1: ForcedDocument
11
+
12
+ ```ruby
13
+ # lib/inkpen/extensions/forced_document.rb
14
+ # frozen_string_literal: true
15
+
16
+ module Inkpen
17
+ module Extensions
18
+ class ForcedDocument < Base
19
+ DEFAULT_TITLE_LEVEL = 1
20
+ DEFAULT_SUBTITLE_LEVEL = 2
21
+ DEFAULT_TITLE_PLACEHOLDER = "Untitled"
22
+ DEFAULT_SUBTITLE_PLACEHOLDER = "Add a subtitle..."
23
+
24
+ def name
25
+ :forced_document
26
+ end
27
+
28
+ def title_level
29
+ options.fetch(:title_level, options.fetch(:heading_level, DEFAULT_TITLE_LEVEL))
30
+ end
31
+
32
+ def subtitle_level
33
+ options.fetch(:subtitle_level, DEFAULT_SUBTITLE_LEVEL)
34
+ end
35
+
36
+ def subtitle?
37
+ options.fetch(:subtitle, false)
38
+ end
39
+
40
+ def title_placeholder
41
+ options.fetch(:title_placeholder, options.fetch(:placeholder, DEFAULT_TITLE_PLACEHOLDER))
42
+ end
43
+
44
+ def subtitle_placeholder
45
+ options.fetch(:subtitle_placeholder, DEFAULT_SUBTITLE_PLACEHOLDER)
46
+ end
47
+
48
+ def allow_title_deletion?
49
+ options.fetch(:allow_deletion, false)
50
+ end
51
+
52
+ def allow_subtitle_deletion?
53
+ options.fetch(:allow_subtitle_deletion, true)
54
+ end
55
+
56
+ def content_expression
57
+ if subtitle?
58
+ "heading heading? block*"
59
+ else
60
+ "heading block*"
61
+ end
62
+ end
63
+
64
+ def to_config
65
+ config = {
66
+ titleLevel: title_level,
67
+ titlePlaceholder: title_placeholder,
68
+ allowDeletion: allow_title_deletion?,
69
+ contentExpression: content_expression,
70
+ subtitle: subtitle?
71
+ }
72
+
73
+ if subtitle?
74
+ config.merge!(
75
+ subtitleLevel: subtitle_level,
76
+ subtitlePlaceholder: subtitle_placeholder,
77
+ allowSubtitleDeletion: allow_subtitle_deletion?
78
+ )
79
+ end
80
+
81
+ config[:headingLevel] = title_level
82
+ config
83
+ end
84
+
85
+ private
86
+
87
+ def default_options
88
+ super.merge(
89
+ title_level: DEFAULT_TITLE_LEVEL,
90
+ subtitle_level: DEFAULT_SUBTITLE_LEVEL,
91
+ title_placeholder: DEFAULT_TITLE_PLACEHOLDER,
92
+ subtitle_placeholder: DEFAULT_SUBTITLE_PLACEHOLDER,
93
+ subtitle: false,
94
+ allow_deletion: false,
95
+ allow_subtitle_deletion: true
96
+ )
97
+ end
98
+ end
99
+ end
100
+ end
101
+ ```
102
+
103
+ ### A2: CodeBlockSyntax
104
+
105
+ ```ruby
106
+ # lib/inkpen/extensions/code_block_syntax.rb
107
+ # frozen_string_literal: true
108
+
109
+ module Inkpen
110
+ module Extensions
111
+ class CodeBlockSyntax < Base
112
+ AVAILABLE_LANGUAGES = %i[
113
+ javascript typescript ruby python css xml html json
114
+ bash shell sql markdown yaml go rust java kotlin swift
115
+ php c cpp csharp elixir erlang haskell scala r matlab
116
+ dockerfile nginx apache graphql
117
+ ].freeze
118
+
119
+ DEFAULT_LANGUAGES = %i[
120
+ javascript typescript ruby python css xml json
121
+ bash sql markdown
122
+ ].freeze
123
+
124
+ def name
125
+ :code_block_syntax
126
+ end
127
+
128
+ def languages
129
+ options.fetch(:languages, DEFAULT_LANGUAGES)
130
+ end
131
+
132
+ def default_language
133
+ options[:default_language]
134
+ end
135
+
136
+ def line_numbers?
137
+ options.fetch(:line_numbers, false)
138
+ end
139
+
140
+ def show_language_selector?
141
+ options.fetch(:language_selector, true)
142
+ end
143
+
144
+ def copy_button?
145
+ options.fetch(:copy_button, true)
146
+ end
147
+
148
+ def theme
149
+ options.fetch(:theme, "github")
150
+ end
151
+
152
+ def to_config
153
+ {
154
+ languages: languages,
155
+ defaultLanguage: default_language,
156
+ lineNumbers: line_numbers?,
157
+ languageSelector: show_language_selector?,
158
+ copyButton: copy_button?,
159
+ theme: theme,
160
+ lowlight: {
161
+ languages: languages
162
+ }
163
+ }
164
+ end
165
+
166
+ private
167
+
168
+ def default_options
169
+ super.merge(
170
+ languages: DEFAULT_LANGUAGES,
171
+ line_numbers: false,
172
+ language_selector: true,
173
+ copy_button: true,
174
+ theme: "github"
175
+ )
176
+ end
177
+ end
178
+ end
179
+ end
180
+ ```
181
+
182
+ ### A3: TaskList
183
+
184
+ ```ruby
185
+ # lib/inkpen/extensions/task_list.rb
186
+ # frozen_string_literal: true
187
+
188
+ module Inkpen
189
+ module Extensions
190
+ class TaskList < Base
191
+ def name
192
+ :task_list
193
+ end
194
+
195
+ def nested?
196
+ options.fetch(:nested, true)
197
+ end
198
+
199
+ def list_class
200
+ options.fetch(:list_class, "inkpen-task-list")
201
+ end
202
+
203
+ def item_class
204
+ options.fetch(:item_class, "inkpen-task-item")
205
+ end
206
+
207
+ def checked_class
208
+ options.fetch(:checked_class, "inkpen-task-checked")
209
+ end
210
+
211
+ def checkbox_class
212
+ options.fetch(:checkbox_class, "inkpen-task-checkbox")
213
+ end
214
+
215
+ def html_attributes
216
+ options.fetch(:html_attributes, { class: list_class })
217
+ end
218
+
219
+ def item_html_attributes
220
+ options.fetch(:item_html_attributes, { class: item_class })
221
+ end
222
+
223
+ def text_toggle?
224
+ options.fetch(:text_toggle, false)
225
+ end
226
+
227
+ def keyboard_shortcut
228
+ options.fetch(:keyboard_shortcut, "Mod-Shift-9")
229
+ end
230
+
231
+ def to_config
232
+ {
233
+ nested: nested?,
234
+ listClass: list_class,
235
+ itemClass: item_class,
236
+ checkedClass: checked_class,
237
+ checkboxClass: checkbox_class,
238
+ HTMLAttributes: html_attributes,
239
+ itemHTMLAttributes: item_html_attributes,
240
+ textToggle: text_toggle?,
241
+ keyboardShortcut: keyboard_shortcut
242
+ }
243
+ end
244
+
245
+ private
246
+
247
+ def default_options
248
+ super.merge(
249
+ nested: true,
250
+ text_toggle: false,
251
+ keyboard_shortcut: "Mod-Shift-9"
252
+ )
253
+ end
254
+ end
255
+ end
256
+ end
257
+ ```
258
+
259
+ ### A4: Table
260
+
261
+ ```ruby
262
+ # lib/inkpen/extensions/table.rb
263
+ # frozen_string_literal: true
264
+
265
+ module Inkpen
266
+ module Extensions
267
+ class Table < Base
268
+ DEFAULT_CELL_MIN_WIDTH = 25
269
+ DEFAULT_CELL_MAX_WIDTH = 500
270
+
271
+ def name
272
+ :table
273
+ end
274
+
275
+ def resizable?
276
+ options.fetch(:resizable, true)
277
+ end
278
+
279
+ def header_row?
280
+ options.fetch(:header_row, true)
281
+ end
282
+
283
+ def header_column?
284
+ options.fetch(:header_column, false)
285
+ end
286
+
287
+ def cell_min_width
288
+ options.fetch(:cell_min_width, DEFAULT_CELL_MIN_WIDTH)
289
+ end
290
+
291
+ def cell_max_width
292
+ options[:cell_max_width]
293
+ end
294
+
295
+ def toolbar?
296
+ options.fetch(:toolbar, true)
297
+ end
298
+
299
+ def cell_backgrounds?
300
+ options.fetch(:cell_backgrounds, true)
301
+ end
302
+
303
+ def allow_merge?
304
+ options.fetch(:allow_merge, true)
305
+ end
306
+
307
+ def default_rows
308
+ options.fetch(:default_rows, 3)
309
+ end
310
+
311
+ def default_cols
312
+ options.fetch(:default_cols, 3)
313
+ end
314
+
315
+ def with_header_row?
316
+ options.fetch(:with_header_row, true)
317
+ end
318
+
319
+ def to_config
320
+ {
321
+ resizable: resizable?,
322
+ headerRow: header_row?,
323
+ headerColumn: header_column?,
324
+ cellMinWidth: cell_min_width,
325
+ cellMaxWidth: cell_max_width,
326
+ toolbar: toolbar?,
327
+ cellBackgrounds: cell_backgrounds?,
328
+ allowMerge: allow_merge?,
329
+ defaultRows: default_rows,
330
+ defaultCols: default_cols,
331
+ withHeaderRow: with_header_row?
332
+ }.compact
333
+ end
334
+
335
+ private
336
+
337
+ def default_options
338
+ super.merge(
339
+ resizable: true,
340
+ header_row: true,
341
+ header_column: false,
342
+ cell_min_width: DEFAULT_CELL_MIN_WIDTH,
343
+ toolbar: true,
344
+ cell_backgrounds: true,
345
+ allow_merge: true,
346
+ default_rows: 3,
347
+ default_cols: 3,
348
+ with_header_row: true
349
+ )
350
+ end
351
+ end
352
+ end
353
+ end
354
+ ```
355
+
356
+ ### A5: Mention
357
+
358
+ ```ruby
359
+ # lib/inkpen/extensions/mention.rb
360
+ # frozen_string_literal: true
361
+
362
+ module Inkpen
363
+ module Extensions
364
+ class Mention < Base
365
+ DEFAULT_TRIGGER = "@"
366
+ DEFAULT_MIN_CHARS = 1
367
+
368
+ def name
369
+ :mention
370
+ end
371
+
372
+ def trigger
373
+ options.fetch(:trigger, DEFAULT_TRIGGER)
374
+ end
375
+
376
+ def search_url
377
+ options[:search_url]
378
+ end
379
+
380
+ def items
381
+ options[:items]
382
+ end
383
+
384
+ def min_chars
385
+ options.fetch(:min_chars, DEFAULT_MIN_CHARS)
386
+ end
387
+
388
+ def suggestion_class
389
+ options.fetch(:suggestion_class, "inkpen-mention-suggestions")
390
+ end
391
+
392
+ def item_class
393
+ options.fetch(:item_class, "inkpen-mention-item")
394
+ end
395
+
396
+ def allow_custom?
397
+ options.fetch(:allow_custom, false)
398
+ end
399
+
400
+ def html_attributes
401
+ options.fetch(:html_attributes, { class: "inkpen-mention" })
402
+ end
403
+
404
+ def to_config
405
+ {
406
+ trigger: trigger,
407
+ searchUrl: search_url,
408
+ items: items,
409
+ minChars: min_chars,
410
+ suggestionClass: suggestion_class,
411
+ itemClass: item_class,
412
+ allowCustom: allow_custom?,
413
+ HTMLAttributes: html_attributes
414
+ }.compact
415
+ end
416
+
417
+ private
418
+
419
+ def default_options
420
+ super.merge(
421
+ trigger: DEFAULT_TRIGGER,
422
+ min_chars: DEFAULT_MIN_CHARS,
423
+ allow_custom: false
424
+ )
425
+ end
426
+ end
427
+ end
428
+ end
429
+ ```
430
+
431
+ ### A6: SlashCommands
432
+
433
+ ```ruby
434
+ # lib/inkpen/extensions/slash_commands.rb
435
+ # frozen_string_literal: true
436
+
437
+ module Inkpen
438
+ module Extensions
439
+ class SlashCommands < Base
440
+ DEFAULT_COMMANDS = [
441
+ { name: "paragraph", label: "Text", description: "Plain text block", icon: "text", group: "Basic", shortcut: nil },
442
+ { name: "heading1", label: "Heading 1", description: "Large heading", icon: "h1", group: "Basic", shortcut: "#" },
443
+ { name: "heading2", label: "Heading 2", description: "Medium heading", icon: "h2", group: "Basic", shortcut: "##" },
444
+ { name: "heading3", label: "Heading 3", description: "Small heading", icon: "h3", group: "Basic", shortcut: "###" },
445
+ { name: "bullet_list", label: "Bullet List", description: "Unordered list", icon: "list", group: "Lists", shortcut: "-" },
446
+ { name: "ordered_list", label: "Numbered List", description: "Ordered list", icon: "list-ordered", group: "Lists", shortcut: "1." },
447
+ { name: "task_list", label: "Task List", description: "Checklist with checkboxes", icon: "check-square", group: "Lists", shortcut: "[]" },
448
+ { name: "blockquote", label: "Quote", description: "Quote block", icon: "quote", group: "Blocks", shortcut: ">" },
449
+ { name: "code_block", label: "Code Block", description: "Code with syntax highlighting", icon: "code", group: "Blocks", shortcut: "```" },
450
+ { name: "horizontal_rule", label: "Divider", description: "Horizontal line", icon: "minus", group: "Blocks", shortcut: "---" },
451
+ { name: "image", label: "Image", description: "Upload or embed an image", icon: "image", group: "Media", shortcut: nil },
452
+ { name: "youtube", label: "YouTube", description: "Embed a YouTube video", icon: "youtube", group: "Media", shortcut: nil },
453
+ { name: "table", label: "Table", description: "Insert a table", icon: "table", group: "Advanced", shortcut: nil },
454
+ { name: "callout", label: "Callout", description: "Highlighted callout box", icon: "alert-circle", group: "Advanced", shortcut: nil }
455
+ ].freeze
456
+
457
+ DEFAULT_GROUPS = %w[Basic Lists Blocks Media Advanced].freeze
458
+
459
+ def name
460
+ :slash_commands
461
+ end
462
+
463
+ def trigger
464
+ options.fetch(:trigger, "/")
465
+ end
466
+
467
+ def commands
468
+ options.fetch(:commands, DEFAULT_COMMANDS)
469
+ end
470
+
471
+ def groups
472
+ options.fetch(:groups, DEFAULT_GROUPS)
473
+ end
474
+
475
+ def max_suggestions
476
+ options.fetch(:max_suggestions, 10)
477
+ end
478
+
479
+ def allow_filtering?
480
+ options.fetch(:allow_filtering, true)
481
+ end
482
+
483
+ def show_icons?
484
+ options.fetch(:show_icons, true)
485
+ end
486
+
487
+ def show_descriptions?
488
+ options.fetch(:show_descriptions, true)
489
+ end
490
+
491
+ def show_shortcuts?
492
+ options.fetch(:show_shortcuts, false)
493
+ end
494
+
495
+ def suggestion_class
496
+ options.fetch(:suggestion_class, "inkpen-slash-menu")
497
+ end
498
+
499
+ def item_class
500
+ options.fetch(:item_class, "inkpen-slash-item")
501
+ end
502
+
503
+ def active_class
504
+ options.fetch(:active_class, "is-selected")
505
+ end
506
+
507
+ def group_class
508
+ options.fetch(:group_class, "inkpen-slash-group")
509
+ end
510
+
511
+ def to_config
512
+ {
513
+ trigger: trigger,
514
+ commands: commands,
515
+ groups: groups,
516
+ maxSuggestions: max_suggestions,
517
+ allowFiltering: allow_filtering?,
518
+ showIcons: show_icons?,
519
+ showDescriptions: show_descriptions?,
520
+ showShortcuts: show_shortcuts?,
521
+ suggestionClass: suggestion_class,
522
+ itemClass: item_class,
523
+ activeClass: active_class,
524
+ groupClass: group_class
525
+ }.compact
526
+ end
527
+
528
+ private
529
+
530
+ def default_options
531
+ super.merge(
532
+ trigger: "/",
533
+ commands: DEFAULT_COMMANDS,
534
+ groups: DEFAULT_GROUPS,
535
+ max_suggestions: 10,
536
+ allow_filtering: true,
537
+ show_icons: true,
538
+ show_descriptions: true,
539
+ show_shortcuts: false,
540
+ suggestion_class: "inkpen-slash-menu",
541
+ item_class: "inkpen-slash-item",
542
+ active_class: "is-selected",
543
+ group_class: "inkpen-slash-group"
544
+ )
545
+ end
546
+ end
547
+ end
548
+ end
549
+ ```
550
+
551
+ ---
552
+
553
+ ## B. Feature Sets
554
+
555
+ ### B1: Page Builder Feature Set
556
+
557
+ ```ruby
558
+ # lib/inkpen/editor.rb - FEATURE_SETS[:page_builder]
559
+
560
+ module Inkpen
561
+ class TiptapEditor
562
+ FEATURE_SETS = {
563
+ page_builder: {
564
+ text: [:bold, :italic, :underline, :strike],
565
+ headings: [1, 2, 3, 4, 5, 6],
566
+ lists: [:bullet, :ordered, :task],
567
+ media: [:image, :video, :embed],
568
+ layout: [:columns, :hero, :cta, :testimonial, :features_grid],
569
+ advanced: [:table, :code_block, :blockquote, :callout],
570
+ collaboration: [:comments]
571
+ },
572
+
573
+ document: {
574
+ text: [:bold, :italic, :code],
575
+ headings: [1, 2, 3],
576
+ lists: [:bullet, :ordered],
577
+ code: [:code_block, :syntax_highlight],
578
+ collaboration: [:comments, :suggestions, :mentions],
579
+ advanced: [:table, :table_of_contents]
580
+ },
581
+
582
+ standard: {
583
+ text: [:bold, :italic, :underline],
584
+ headings: [1, 2, 3],
585
+ lists: [:bullet, :ordered],
586
+ media: [:image, :link],
587
+ advanced: [:blockquote, :code_block, :callout]
588
+ }
589
+ }.freeze
590
+
591
+ class << self
592
+ def extensions_for(feature_set)
593
+ features = FEATURE_SETS[feature_set.to_sym] || FEATURE_SETS[:standard]
594
+ build_extensions(features)
595
+ end
596
+
597
+ private
598
+
599
+ def build_extensions(features)
600
+ extensions = []
601
+
602
+ # Core extensions always included
603
+ extensions << Extensions::ForcedDocument.new(
604
+ subtitle: features[:headings]&.include?(2),
605
+ title_level: 1
606
+ )
607
+
608
+ # Code block if code features requested
609
+ if features[:code]&.include?(:code_block) || features[:advanced]&.include?(:code_block)
610
+ extensions << Extensions::CodeBlockSyntax.new(
611
+ languages: %i[ruby javascript python sql html css],
612
+ line_numbers: features[:code]&.any?,
613
+ copy_button: true
614
+ )
615
+ end
616
+
617
+ # Task list if lists requested
618
+ if features[:lists]&.include?(:task)
619
+ extensions << Extensions::TaskList.new(nested: true)
620
+ end
621
+
622
+ # Table if advanced table requested
623
+ if features[:advanced]&.include?(:table)
624
+ extensions << Extensions::Table.new(
625
+ resizable: true,
626
+ header_row: true,
627
+ allow_merge: true
628
+ )
629
+ end
630
+
631
+ # Mentions if collaboration features
632
+ if features[:collaboration]&.include?(:mentions)
633
+ extensions << Extensions::Mention.new(
634
+ search_url: "/api/users/search",
635
+ trigger: "@"
636
+ )
637
+ end
638
+
639
+ # Slash commands always included
640
+ extensions << Extensions::SlashCommands.new(
641
+ trigger: "/",
642
+ show_icons: true,
643
+ show_descriptions: true
644
+ )
645
+
646
+ extensions
647
+ end
648
+ end
649
+ end
650
+ end
651
+ ```
652
+
653
+ ### B2: Document Feature Set
654
+
655
+ (Already included above in B1 as part of FEATURE_SETS)
656
+
657
+ ### B3: Standard Feature Set
658
+
659
+ (Already included above in B1 as part of FEATURE_SETS)
660
+
661
+ ---
662
+
663
+ ## C. Integration Pattern
664
+
665
+ ### C1: Rails Controller
666
+
667
+ ```ruby
668
+ # app/controllers/inkpen/extensions_controller.rb
669
+
670
+ module Inkpen
671
+ class ExtensionsController < ApplicationController
672
+ skip_before_action :verify_authenticity_token, only: [:show]
673
+
674
+ def show
675
+ feature_set = params[:feature_set].to_sym
676
+ extensions = TiptapEditor.extensions_for(feature_set)
677
+
678
+ render json: {
679
+ extensions: extensions.map { |ext| ext.to_config if ext.enabled? }.compact,
680
+ version: Inkpen::VERSION,
681
+ success: true
682
+ }
683
+ rescue => e
684
+ render json: {
685
+ error: e.message,
686
+ success: false
687
+ }, status: :unprocessable_entity
688
+ end
689
+ end
690
+ end
691
+ ```
692
+
693
+ ### C2: View Helper
694
+
695
+ ```ruby
696
+ # app/helpers/inkpen/editor_helper.rb
697
+
698
+ module Inkpen
699
+ module EditorHelper
700
+ def tiptap_editor(model, field, options = {})
701
+ controller_name = options[:controller] || model.class.name.underscore
702
+ features = options[:features] || :standard
703
+
704
+ render "inkpen/editor",
705
+ model: model,
706
+ field: field,
707
+ controller_name: controller_name,
708
+ features: features,
709
+ options: options
710
+ end
711
+ end
712
+ end
713
+ ```
714
+
715
+ ### C3: Stimulus Controller
716
+
717
+ ```javascript
718
+ // app/javascript/controllers/inkpen/editor_controller.js
719
+
720
+ import { Controller } from "@hotwired/stimulus"
721
+ import { Editor } from "@tiptap/core"
722
+ import StarterKit from "@tiptap/starter-kit"
723
+ import ExtensionsLoader from "../utils/extensions_loader"
724
+
725
+ export default class extends Controller {
726
+ static targets = ["editor", "content", "title"]
727
+ static values = { features: String, autosave: Boolean }
728
+
729
+ connect() {
730
+ this.initEditor()
731
+ }
732
+
733
+ async initEditor() {
734
+ try {
735
+ const config = await this.fetchExtensionsConfig()
736
+ const extensions = await ExtensionsLoader.load(config)
737
+
738
+ this.editor = new Editor({
739
+ element: this.editorTarget,
740
+ extensions: [
741
+ StarterKit.configure({
742
+ heading: { levels: [1, 2, 3, 4, 5, 6] }
743
+ }),
744
+ ...extensions
745
+ ],
746
+ content: this.contentTarget.value,
747
+ onUpdate: ({ editor }) => {
748
+ this.contentTarget.value = editor.getHTML()
749
+
750
+ if (this.autosaveValue) {
751
+ this.debounce(() => this.autosave(), 1500)
752
+ }
753
+ }
754
+ })
755
+
756
+ this.attachObservers()
757
+ } catch (error) {
758
+ console.error("Failed to initialize editor:", error)
759
+ this.editorTarget.innerHTML = '<p style="color: red;">Failed to load editor</p>'
760
+ }
761
+ }
762
+
763
+ async fetchExtensionsConfig() {
764
+ const response = await fetch(`/inkpen/extensions/${this.featuresValue}.json`)
765
+ if (!response.ok) throw new Error(`Failed to fetch extensions: ${response.status}`)
766
+ return await response.json()
767
+ }
768
+
769
+ autosave() {
770
+ if (!this.form) return
771
+
772
+ const formData = new FormData(this.form)
773
+ fetch(this.form.action, {
774
+ method: "PATCH",
775
+ body: formData,
776
+ headers: { "X-CSRF-Token": this.csrfToken }
777
+ })
778
+ .then(r => {
779
+ if (r.ok) this.showAutosaveIndicator()
780
+ })
781
+ .catch(e => console.error("Autosave failed:", e))
782
+ }
783
+
784
+ showAutosaveIndicator() {
785
+ // Show "Saved" indicator briefly
786
+ const indicator = document.createElement("div")
787
+ indicator.className = "autosave-indicator"
788
+ indicator.textContent = "✓ Saved"
789
+ document.body.appendChild(indicator)
790
+
791
+ setTimeout(() => indicator.remove(), 2000)
792
+ }
793
+
794
+ attachObservers() {
795
+ // Additional event handlers
796
+ }
797
+
798
+ disconnect() {
799
+ if (this.editor) this.editor.destroy()
800
+ clearTimeout(this.debounceTimer)
801
+ }
802
+
803
+ debounce(fn, delay) {
804
+ clearTimeout(this.debounceTimer)
805
+ this.debounceTimer = setTimeout(fn, delay)
806
+ }
807
+
808
+ get form() {
809
+ return this.element.closest("form")
810
+ }
811
+
812
+ get csrfToken() {
813
+ return document.querySelector('meta[name="csrf-token"]')?.content || ""
814
+ }
815
+ }
816
+ ```
817
+
818
+ ### C4: Extensions Loader
819
+
820
+ ```javascript
821
+ // app/javascript/utils/extensions_loader.js
822
+
823
+ import { CodeBlockLowlight } from "@tiptap/extension-code-block-lowlight"
824
+ import { lowlight } from "lowlight"
825
+ import { TaskList } from "@tiptap/extension-task-list"
826
+ import { TaskItem } from "@tiptap/extension-task-item"
827
+ import { Table } from "@tiptap/extension-table"
828
+ import { TableRow } from "@tiptap/extension-table-row"
829
+ import { TableHeader } from "@tiptap/extension-table-header"
830
+ import { TableCell } from "@tiptap/extension-table-cell"
831
+ import { Mention } from "@tiptap/extension-mention"
832
+ import MentionList from "./components/mention_list"
833
+ import CommandPalette from "./components/command_palette"
834
+ import tippy from "tippy.js"
835
+
836
+ export default class ExtensionsLoader {
837
+ static async load(config) {
838
+ const extensions = []
839
+
840
+ for (const extConfig of config.extensions) {
841
+ const ext = await this.loadExtension(extConfig)
842
+ if (ext) extensions.push(ext)
843
+ }
844
+
845
+ return extensions
846
+ }
847
+
848
+ static async loadExtension(config) {
849
+ switch (config.name) {
850
+ case "forced_document":
851
+ return this.configureForcedDocument(config)
852
+
853
+ case "code_block_syntax":
854
+ return this.configureCodeBlock(config)
855
+
856
+ case "task_list":
857
+ return this.configureTaskList(config)
858
+
859
+ case "table":
860
+ return this.configureTable(config)
861
+
862
+ case "mention":
863
+ return this.configureMention(config)
864
+
865
+ case "slash_commands":
866
+ return this.configureSlashCommands(config)
867
+
868
+ default:
869
+ return null
870
+ }
871
+ }
872
+
873
+ static configureForcedDocument(config) {
874
+ // Load ForcedDocument extension from TipTap
875
+ const ForcedDocument = require("@tiptap/extension-forced-document").default
876
+
877
+ return ForcedDocument.configure({
878
+ titleLevel: config.config.titleLevel,
879
+ titlePlaceholder: config.config.titlePlaceholder,
880
+ subtitle: config.config.subtitle,
881
+ subtitleLevel: config.config.subtitleLevel
882
+ })
883
+ }
884
+
885
+ static configureCodeBlock(config) {
886
+ const languages = {}
887
+ for (const lang of config.config.languages) {
888
+ languages[lang] = require(`highlight.js/lib/languages/${lang}`).default
889
+ }
890
+
891
+ return CodeBlockLowlight.configure({
892
+ lowlight,
893
+ languages,
894
+ defaultLanguage: config.config.defaultLanguage
895
+ })
896
+ }
897
+
898
+ static configureTaskList(config) {
899
+ return [
900
+ TaskList.configure(config.config),
901
+ TaskItem.configure({ nested: config.config.nested })
902
+ ]
903
+ }
904
+
905
+ static configureTable(config) {
906
+ return [
907
+ Table.configure({
908
+ resizable: config.config.resizable,
909
+ handleWidth: 4,
910
+ cellMinWidth: config.config.cellMinWidth,
911
+ lastColumnResizable: true
912
+ }),
913
+ TableRow,
914
+ TableHeader,
915
+ TableCell
916
+ ]
917
+ }
918
+
919
+ static configureMention(config) {
920
+ return Mention.configure({
921
+ HTMLAttributes: { class: "mention" },
922
+
923
+ suggestion: {
924
+ items: async ({ query }) => {
925
+ if (!query) return []
926
+
927
+ try {
928
+ const response = await fetch(
929
+ `${config.config.searchUrl}?query=${encodeURIComponent(query)}`
930
+ )
931
+ return await response.json()
932
+ } catch (e) {
933
+ console.error("Mention search failed:", e)
934
+ return []
935
+ }
936
+ },
937
+
938
+ render: () => {
939
+ let component
940
+ let popup
941
+
942
+ return {
943
+ onStart: props => {
944
+ component = new MentionList(props)
945
+ popup = tippy("body", {
946
+ getReferenceClientRect: props.clientRect,
947
+ appendTo: () => document.body,
948
+ content: component.element,
949
+ showOnCreate: true,
950
+ interactive: true,
951
+ trigger: "manual",
952
+ placement: "bottom-start"
953
+ })[0]
954
+ },
955
+
956
+ onUpdate(props) {
957
+ component.update(props)
958
+ },
959
+
960
+ onKeyDown(props) {
961
+ return component.onKeyDown(props)
962
+ },
963
+
964
+ onExit() {
965
+ popup?.destroy()
966
+ component?.destroy()
967
+ }
968
+ }
969
+ }
970
+ }
971
+ })
972
+ }
973
+
974
+ static configureSlashCommands(config) {
975
+ const SlashCommand = require("@tiptap/extension-slash-command").default
976
+
977
+ return SlashCommand.configure({
978
+ suggestion: {
979
+ items: ({ query }) => {
980
+ return config.config.commands
981
+ .filter(cmd =>
982
+ cmd.label.toLowerCase().startsWith(query.toLowerCase())
983
+ )
984
+ .slice(0, config.config.maxSuggestions)
985
+ },
986
+
987
+ render: () => {
988
+ let component
989
+ let popup
990
+
991
+ return {
992
+ onStart: props => {
993
+ component = new CommandPalette(props)
994
+ popup = tippy("body", {
995
+ getReferenceClientRect: props.clientRect,
996
+ appendTo: () => document.body,
997
+ content: component.element,
998
+ showOnCreate: true,
999
+ interactive: true,
1000
+ trigger: "manual",
1001
+ placement: "bottom-start"
1002
+ })[0]
1003
+ },
1004
+
1005
+ onUpdate(props) {
1006
+ component.update(props)
1007
+ },
1008
+
1009
+ onKeyDown(props) {
1010
+ return component.onKeyDown(props)
1011
+ },
1012
+
1013
+ onExit() {
1014
+ popup?.destroy()
1015
+ component?.destroy()
1016
+ }
1017
+ }
1018
+ }
1019
+ }
1020
+ })
1021
+ }
1022
+ }
1023
+ ```
1024
+
1025
+ ### C5: TipTap Initialization
1026
+
1027
+ (Covered in C3 Stimulus Controller above)
1028
+
1029
+ ---
1030
+
1031
+ ## D. Custom Blocks
1032
+
1033
+ ### D1: Custom Block - Hero
1034
+
1035
+ ```ruby
1036
+ # lib/inkpen/extensions/hero.rb
1037
+ # frozen_string_literal: true
1038
+
1039
+ module Inkpen
1040
+ module Extensions
1041
+ class Hero < Base
1042
+ def name
1043
+ :hero
1044
+ end
1045
+
1046
+ def background_image_url
1047
+ options[:background_image_url]
1048
+ end
1049
+
1050
+ def headline
1051
+ options[:headline]
1052
+ end
1053
+
1054
+ def subheadline
1055
+ options[:subheadline]
1056
+ end
1057
+
1058
+ def cta_text
1059
+ options[:cta_text]
1060
+ end
1061
+
1062
+ def cta_url
1063
+ options[:cta_url]
1064
+ end
1065
+
1066
+ def text_align
1067
+ options.fetch(:text_align, "center") # left, center, right
1068
+ end
1069
+
1070
+ def dark_overlay?
1071
+ options.fetch(:dark_overlay, false)
1072
+ end
1073
+
1074
+ def to_config
1075
+ {
1076
+ backgroundImageUrl: background_image_url,
1077
+ headline: headline,
1078
+ subheadline: subheadline,
1079
+ ctaText: cta_text,
1080
+ ctaUrl: cta_url,
1081
+ textAlign: text_align,
1082
+ darkOverlay: dark_overlay?
1083
+ }.compact
1084
+ end
1085
+
1086
+ private
1087
+
1088
+ def default_options
1089
+ super.merge(
1090
+ text_align: "center",
1091
+ dark_overlay: false
1092
+ )
1093
+ end
1094
+ end
1095
+ end
1096
+ end
1097
+ ```
1098
+
1099
+ JavaScript implementation:
1100
+
1101
+ ```javascript
1102
+ // app/javascript/extensions/hero.js
1103
+
1104
+ import { Node } from "@tiptap/core"
1105
+ import { VueNodeViewRenderer } from "@tiptap/vue-3"
1106
+ import HeroComponent from "../components/hero_block.vue"
1107
+
1108
+ export const HeroBlock = Node.create({
1109
+ name: "hero",
1110
+ group: "block",
1111
+ atom: true,
1112
+ draggable: true,
1113
+ selectable: true,
1114
+
1115
+ addAttributes() {
1116
+ return {
1117
+ backgroundImageUrl: { default: null },
1118
+ headline: { default: "Your Headline Here" },
1119
+ subheadline: { default: "Add a subheadline" },
1120
+ ctaText: { default: "Learn More" },
1121
+ ctaUrl: { default: "#" },
1122
+ textAlign: { default: "center" },
1123
+ darkOverlay: { default: false }
1124
+ }
1125
+ },
1126
+
1127
+ parseHTML() {
1128
+ return [{ tag: "hero-block" }]
1129
+ },
1130
+
1131
+ renderHTML({ HTMLAttributes }) {
1132
+ return ["hero-block", HTMLAttributes]
1133
+ },
1134
+
1135
+ addNodeView() {
1136
+ return VueNodeViewRenderer(HeroComponent)
1137
+ }
1138
+ })
1139
+ ```
1140
+
1141
+ ---
1142
+
1143
+ ## E. Implementation Steps
1144
+
1145
+ ### E1: Gem Setup
1146
+
1147
+ ```ruby
1148
+ # lib/inkpen.rb
1149
+ require "inkpen/version"
1150
+ require "inkpen/engine"
1151
+ require "inkpen/extensions"
1152
+
1153
+ module Inkpen
1154
+ class << self
1155
+ attr_accessor :configuration
1156
+
1157
+ def configure
1158
+ self.configuration ||= Configuration.new
1159
+ yield(configuration)
1160
+ end
1161
+
1162
+ def config
1163
+ configuration ||= Configuration.new
1164
+ end
1165
+ end
1166
+ end
1167
+
1168
+ # lib/inkpen/engine.rb
1169
+ module Inkpen
1170
+ class Engine < ::Rails::Engine
1171
+ isolate_namespace Inkpen
1172
+
1173
+ initializer "inkpen.assets" do |app|
1174
+ app.config.assets.precompile += %w[inkpen_manifest.js]
1175
+ end
1176
+
1177
+ initializer "inkpen.helpers" do
1178
+ ActiveSupport.on_load(:action_controller_base) do
1179
+ helper Inkpen::EditorHelper
1180
+ end
1181
+ end
1182
+ end
1183
+ end
1184
+
1185
+ # lib/inkpen/configuration.rb
1186
+ module Inkpen
1187
+ class Configuration
1188
+ attr_accessor :app_name, :custom_blocks, :feature_sets
1189
+
1190
+ def initialize
1191
+ @app_name = "inkpen"
1192
+ @custom_blocks = []
1193
+ @feature_sets = {}
1194
+ end
1195
+
1196
+ def register_block(name, block_class)
1197
+ @custom_blocks << { name: name, class: block_class }
1198
+ end
1199
+ end
1200
+ end
1201
+
1202
+ # inkpen.gemspec
1203
+ Gem::Specification.new do |spec|
1204
+ spec.name = "inkpen"
1205
+ spec.version = Inkpen::VERSION
1206
+ spec.authors = ["Your Name"]
1207
+ spec.email = ["your.email@example.com"]
1208
+ spec.summary = "Rich text editor for Rails with TipTap"
1209
+ spec.homepage = "https://github.com/yourname/inkpen"
1210
+ spec.license = "MIT"
1211
+
1212
+ spec.files = Dir.glob("lib/**/*") + Dir.glob("app/**/*") + %w[README.md]
1213
+
1214
+ spec.add_dependency "rails", ">= 7.0"
1215
+ spec.add_dependency "stimulus-rails", ">= 1.0"
1216
+ spec.add_dependency "jsbundling-rails", ">= 1.0"
1217
+
1218
+ spec.add_development_dependency "rspec-rails"
1219
+ spec.add_development_dependency "bundler"
1220
+ spec.add_development_dependency "rake"
1221
+ end
1222
+ ```
1223
+
1224
+ ### E2: Backend Setup
1225
+
1226
+ ```ruby
1227
+ # lib/inkpen/editor.rb
1228
+ module Inkpen
1229
+ class TiptapEditor
1230
+ FEATURE_SETS = {
1231
+ page_builder: { ... },
1232
+ document: { ... },
1233
+ standard: { ... }
1234
+ }
1235
+
1236
+ class << self
1237
+ def extensions_for(feature_set)
1238
+ features = FEATURE_SETS[feature_set.to_sym] || FEATURE_SETS[:standard]
1239
+ build_extensions(features)
1240
+ end
1241
+
1242
+ private
1243
+
1244
+ def build_extensions(features)
1245
+ # Implementation (see section B above)
1246
+ end
1247
+ end
1248
+ end
1249
+ end
1250
+
1251
+ # config/routes.rb (in gem)
1252
+ Inkpen::Engine.routes.draw do
1253
+ namespace :inkpen do
1254
+ resources :extensions, only: [:show]
1255
+ end
1256
+ end
1257
+ ```
1258
+
1259
+ ### E3: Frontend Setup
1260
+
1261
+ (See C3 and C4 above for Stimulus and ExtensionsLoader)
1262
+
1263
+ ### E4: Styling Setup
1264
+
1265
+ ```css
1266
+ /* app/assets/stylesheets/inkpen/editor.css */
1267
+
1268
+ :root {
1269
+ --inkpen-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1270
+ --inkpen-font-size: 1rem;
1271
+ --inkpen-line-height: 1.6;
1272
+ --inkpen-color-text: #1a1a1a;
1273
+ --inkpen-color-text-muted: #666666;
1274
+ --inkpen-color-background: #fafafa;
1275
+ --inkpen-color-border: #e5e5e5;
1276
+ --inkpen-color-primary: #3b82f6;
1277
+ --inkpen-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
1278
+ --inkpen-radius: 6px;
1279
+ }
1280
+
1281
+ .inkpen-editor {
1282
+ font-family: var(--inkpen-font-family);
1283
+ font-size: var(--inkpen-font-size);
1284
+ line-height: var(--inkpen-line-height);
1285
+ color: var(--inkpen-color-text);
1286
+ background: var(--inkpen-color-background);
1287
+ }
1288
+
1289
+ .inkpen-editor__content {
1290
+ outline: none;
1291
+ min-height: 200px;
1292
+ padding: 1rem;
1293
+ }
1294
+
1295
+ /* Dark mode */
1296
+ @media (prefers-color-scheme: dark) {
1297
+ :root {
1298
+ --inkpen-color-text: #ffffff;
1299
+ --inkpen-color-background: #111111;
1300
+ --inkpen-color-primary: #60a5fa;
1301
+ }
1302
+ }
1303
+ ```
1304
+
1305
+ ### E5: Testing Setup
1306
+
1307
+ ```ruby
1308
+ # spec/lib/inkpen/extensions/code_block_syntax_spec.rb
1309
+
1310
+ RSpec.describe Inkpen::Extensions::CodeBlockSyntax do
1311
+ describe "#to_config" do
1312
+ it "converts to JSON-safe hash" do
1313
+ ext = described_class.new(
1314
+ languages: %i[ruby javascript],
1315
+ copy_button: true
1316
+ )
1317
+
1318
+ config = ext.to_config
1319
+ expect(config).to be_a(Hash)
1320
+ expect(config[:languages]).to eq(%i[ruby javascript])
1321
+ expect(config[:copyButton]).to eq(true)
1322
+ end
1323
+ end
1324
+
1325
+ describe "#name" do
1326
+ it "returns :code_block_syntax" do
1327
+ ext = described_class.new
1328
+ expect(ext.name).to eq(:code_block_syntax)
1329
+ end
1330
+ end
1331
+ end
1332
+ ```
1333
+
1334
+ ### E6: Deployment
1335
+
1336
+ ```bash
1337
+ # Build and publish gem
1338
+ gem build inkpen.gemspec
1339
+ # gem push inkpen-1.0.0.gem (to rubygems.org)
1340
+ # or push to private gem server
1341
+
1342
+ # In app Gemfile
1343
+ gem 'inkpen', '~> 1.0.0', git: 'https://github.com/yourname/inkpen.git'
1344
+
1345
+ # Run installer
1346
+ rails generate inkpen:install
1347
+ ```
1348
+
1349
+ ---
1350
+
1351
+ ## F. Testing
1352
+
1353
+ ### F1: Unit Tests
1354
+
1355
+ ```ruby
1356
+ # spec/lib/inkpen/extensions/base_spec.rb
1357
+
1358
+ RSpec.describe Inkpen::Extensions::Base do
1359
+ subject { described_class.new(enabled: true, custom: "value") }
1360
+
1361
+ describe "#initialize" do
1362
+ it "merges options with defaults" do
1363
+ expect(subject.options[:enabled]).to eq(true)
1364
+ expect(subject.options[:custom]).to eq("value")
1365
+ end
1366
+ end
1367
+
1368
+ describe "#enabled?" do
1369
+ it "returns true by default" do
1370
+ ext = described_class.new
1371
+ expect(ext.enabled?).to eq(true)
1372
+ end
1373
+
1374
+ it "respects enabled option" do
1375
+ ext = described_class.new(enabled: false)
1376
+ expect(ext.enabled?).to eq(false)
1377
+ end
1378
+ end
1379
+
1380
+ describe "#to_json" do
1381
+ it "serializes to valid JSON" do
1382
+ ext = described_class.new
1383
+ json = ext.to_json
1384
+ expect { JSON.parse(json) }.not_to raise_error
1385
+ end
1386
+ end
1387
+ end
1388
+ ```
1389
+
1390
+ ### F2: Integration Tests
1391
+
1392
+ ```ruby
1393
+ # spec/requests/inkpen/extensions_spec.rb
1394
+
1395
+ RSpec.describe "Inkpen Extensions" do
1396
+ describe "GET /inkpen/extensions/:feature_set.json" do
1397
+ it "returns page_builder extensions" do
1398
+ get "/inkpen/extensions/page_builder.json"
1399
+ expect(response).to be_successful
1400
+
1401
+ json = JSON.parse(response.body)
1402
+ expect(json["extensions"]).to be_an(Array)
1403
+ expect(json["extensions"].length).to be > 0
1404
+ end
1405
+ end
1406
+ end
1407
+ ```
1408
+
1409
+ ### F3: Browser Tests
1410
+
1411
+ ```javascript
1412
+ // spec/browser/editor_spec.js (Cypress/Playwright)
1413
+
1414
+ describe("Inkpen Editor", () => {
1415
+ before(() => {
1416
+ cy.visit("/pages/new")
1417
+ })
1418
+
1419
+ it("loads editor without errors", () => {
1420
+ cy.get('[data-controller="inkpen--editor"]').should("exist")
1421
+ cy.get('.ProseMirror').should("be.visible")
1422
+ })
1423
+
1424
+ it("allows typing in editor", () => {
1425
+ cy.get('.ProseMirror').type("Hello, world!")
1426
+ cy.get('.ProseMirror').should("contain", "Hello, world!")
1427
+ })
1428
+
1429
+ it("shows slash command palette", () => {
1430
+ cy.get('.ProseMirror').type("/")
1431
+ cy.get('[data-inkpen-target="slash-menu"]').should("be.visible")
1432
+ })
1433
+ })
1434
+ ```
1435
+
1436
+ ---
1437
+
1438
+ ## G. Changelog Template
1439
+
1440
+ ```markdown
1441
+ # CHANGELOG.md
1442
+
1443
+ ## [1.2.0] - 2024-01-15
1444
+
1445
+ ### Added
1446
+ - ForcedDocument extension for document structure enforcement
1447
+ - CodeBlockSyntax extension with 30+ language support
1448
+ - TaskList extension with nesting support
1449
+ - Table extension with cell merging and resizing
1450
+ - Mention extension for @mentions
1451
+ - SlashCommands extension with 13 built-in commands
1452
+ - Page Builder feature set for mademysite.com
1453
+ - Document feature set for kuickr.co
1454
+ - Standard feature set for blog posts
1455
+
1456
+ ### Changed
1457
+ - Refactored extension loading system for better modularity
1458
+ - Simplified Stimulus controller API
1459
+
1460
+ ### Fixed
1461
+ - Fixed dark mode CSS variable scoping
1462
+ - Fixed autosave timing issues
1463
+
1464
+ ## [1.1.0] - 2024-01-01
1465
+
1466
+ ### Added
1467
+ - Initial gem release
1468
+ - Basic Rails::Engine setup
1469
+ - Stimulus controller integration
1470
+
1471
+ ### Fixed
1472
+ - Initial bug fixes from alpha testing
1473
+ ```
1474
+
1475
+ ---
1476
+
1477
+ **End of Code Samples Document**
1478
+
1479
+ All code samples reference either existing files you provided or implementation patterns based on the Inkpen architecture.