keystone_ui-pages 0.1.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.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/app/assets/builds/keystone_ui_pages/editor.js +67 -0
  4. data/app/controllers/concerns/keystone_ui/pages/edits_pages.rb +32 -0
  5. data/app/controllers/concerns/keystone_ui/pages/host_checks.rb +25 -0
  6. data/app/controllers/keystone_ui/pages/manage/base_controller.rb +28 -0
  7. data/app/controllers/keystone_ui/pages/manage/columns_controller.rb +15 -0
  8. data/app/controllers/keystone_ui/pages/manage/documents_controller.rb +15 -0
  9. data/app/controllers/keystone_ui/pages/manage/edit_history_controller.rb +21 -0
  10. data/app/controllers/keystone_ui/pages/manage/elements_controller.rb +30 -0
  11. data/app/controllers/keystone_ui/pages/manage/lives_controller.rb +20 -0
  12. data/app/controllers/keystone_ui/pages/manage/nodes_controller.rb +31 -0
  13. data/app/controllers/keystone_ui/pages/manage/pages_controller.rb +41 -0
  14. data/app/controllers/keystone_ui/pages/manage/pdfs_controller.rb +23 -0
  15. data/app/controllers/keystone_ui/pages/manage/rows_controller.rb +15 -0
  16. data/app/controllers/keystone_ui/pages/manage/samples_controller.rb +16 -0
  17. data/app/controllers/keystone_ui/pages/manage/sections_controller.rb +15 -0
  18. data/app/controllers/keystone_ui/pages/manage/versions_controller.rb +25 -0
  19. data/app/controllers/keystone_ui/pages/pages_controller.rb +26 -0
  20. data/app/helpers/keystone_ui/pages/drawing_helper.rb +115 -0
  21. data/app/helpers/keystone_ui/pages/editor_helper.rb +46 -0
  22. data/app/helpers/keystone_ui/pages/host_helper.rb +17 -0
  23. data/app/javascript/keystone_ui_pages/DeviceWidth.jsx +21 -0
  24. data/app/javascript/keystone_ui_pages/ElementFields.jsx +78 -0
  25. data/app/javascript/keystone_ui_pages/ElementPalette.jsx +62 -0
  26. data/app/javascript/keystone_ui_pages/LayoutPanel.jsx +41 -0
  27. data/app/javascript/keystone_ui_pages/ListField.jsx +49 -0
  28. data/app/javascript/keystone_ui_pages/LookChoice.jsx +17 -0
  29. data/app/javascript/keystone_ui_pages/PageEditor.jsx +260 -0
  30. data/app/javascript/keystone_ui_pages/SampleChoice.jsx +15 -0
  31. data/app/javascript/keystone_ui_pages/SelectionPath.jsx +19 -0
  32. data/app/javascript/keystone_ui_pages/SelectionToolbar.jsx +14 -0
  33. data/app/javascript/keystone_ui_pages/ShowWhen.jsx +32 -0
  34. data/app/javascript/keystone_ui_pages/ThemeMode.jsx +23 -0
  35. data/app/javascript/keystone_ui_pages/document.js +10 -0
  36. data/app/javascript/keystone_ui_pages/editor.jsx +8 -0
  37. data/app/javascript/keystone_ui_pages/operations.js +32 -0
  38. data/app/jobs/keystone_ui/pages/pdf_job.rb +11 -0
  39. data/app/models/keystone_ui/pages/page.rb +55 -0
  40. data/app/models/keystone_ui/pages/pdf.rb +12 -0
  41. data/app/models/keystone_ui/pages/version.rb +13 -0
  42. data/app/views/keystone_ui/pages/manage/pages/index.html.erb +35 -0
  43. data/app/views/keystone_ui/pages/manage/pages/new.html.erb +13 -0
  44. data/app/views/keystone_ui/pages/manage/pages/show.html.erb +8 -0
  45. data/app/views/keystone_ui/pages/manage/versions/index.html.erb +28 -0
  46. data/app/views/keystone_ui/pages/manage/versions/show.html.erb +11 -0
  47. data/app/views/keystone_ui/pages/pages/show.html.erb +3 -0
  48. data/app/views/keystone_ui/pages/pdfs/show.html.erb +12 -0
  49. data/config/routes.rb +31 -0
  50. data/db/migrate/20261014120000_create_keystone_ui_pages_pages.rb +17 -0
  51. data/db/migrate/20261014120001_create_keystone_ui_pages_versions.rb +18 -0
  52. data/db/migrate/20261015130000_create_keystone_ui_pages_pdfs.rb +14 -0
  53. data/db/migrate/20261016120000_add_edit_history_to_keystone_ui_pages_pages.rb +8 -0
  54. data/lib/keystone_ui/pages/catalog.rb +29 -0
  55. data/lib/keystone_ui/pages/document.rb +29 -0
  56. data/lib/keystone_ui/pages/engine.rb +25 -0
  57. data/lib/keystone_ui/pages/keystone_elements.rb +109 -0
  58. data/lib/keystone_ui/pages/operations.rb +246 -0
  59. data/lib/keystone_ui/pages/pdf_document.rb +17 -0
  60. data/lib/keystone_ui/pages/pdfs.rb +20 -0
  61. data/lib/keystone_ui/pages/printer.rb +43 -0
  62. data/lib/keystone_ui/pages/values.rb +28 -0
  63. data/lib/keystone_ui/pages/version.rb +7 -0
  64. data/lib/keystone_ui/pages.rb +45 -0
  65. data/lib/keystone_ui-pages.rb +3 -0
  66. data/the_local/agents/keystone_ui-pages-develop.md +277 -0
  67. data/the_local/agents/keystone_ui-pages-info.md +71 -0
  68. data/the_local/agents/keystone_ui-pages-install.md +167 -0
  69. data/the_local/interface.yml +56 -0
  70. metadata +169 -0
data/config/routes.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ KeystoneUi::Pages::Engine.routes.draw do
4
+ namespace :manage do
5
+ resources :pages, only: %i[index new create show] do
6
+ post :publish, on: :member
7
+ resources :elements, only: %i[create update] do
8
+ post :bind, on: :member
9
+ end
10
+ resources :sections, only: %i[create]
11
+ resources :columns, only: %i[create]
12
+ resources :rows, only: %i[create]
13
+ resources :nodes, only: %i[update destroy] do
14
+ post :move, on: :member
15
+ post :duplicate, on: :member
16
+ post :condition, on: :member
17
+ end
18
+ resource :document, only: %i[update]
19
+ post :sample, to: "samples#update"
20
+ post :undo, to: "edit_history#undo"
21
+ post :redo, to: "edit_history#redo"
22
+ resource :live, only: %i[show]
23
+ resource :pdf, only: %i[show]
24
+ resources :versions, only: %i[index show] do
25
+ post :restore, on: :member
26
+ end
27
+ end
28
+ end
29
+
30
+ get ":slug", to: "pages#show", as: :page
31
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateKeystoneUiPagesPages < ActiveRecord::Migration[7.1]
4
+ def change
5
+ create_table :keystone_ui_pages_pages do |t|
6
+ t.string :name, null: false
7
+ t.string :slug, null: false
8
+ t.json :document, null: false
9
+ t.references :owner, polymorphic: true
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :keystone_ui_pages_pages, [ :owner_type, :owner_id, :slug ], unique: true,
15
+ name: "index_keystone_ui_pages_pages_on_owner_and_slug"
16
+ end
17
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateKeystoneUiPagesVersions < ActiveRecord::Migration[7.1]
4
+ def change
5
+ create_table :keystone_ui_pages_versions do |t|
6
+ t.references :page, null: false, foreign_key: { to_table: :keystone_ui_pages_pages }
7
+ t.integer :number, null: false
8
+ t.json :document, null: false
9
+ t.string :status, null: false
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :keystone_ui_pages_versions, [ :page_id, :number ], unique: true
15
+ add_index :keystone_ui_pages_versions, :page_id, unique: true, where: "status = 'live'",
16
+ name: "index_keystone_ui_pages_versions_on_one_live_per_page"
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateKeystoneUiPagesPdfs < ActiveRecord::Migration[7.1]
4
+ def change
5
+ create_table :keystone_ui_pages_pdfs do |t|
6
+ t.references :version, null: false, foreign_key: { to_table: :keystone_ui_pages_versions }
7
+ t.string :values_digest, null: false
8
+
9
+ t.timestamps
10
+ end
11
+
12
+ add_index :keystone_ui_pages_pdfs, [ :version_id, :values_digest ], unique: true
13
+ end
14
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddEditHistoryToKeystoneUiPagesPages < ActiveRecord::Migration[7.1]
4
+ def change
5
+ add_column :keystone_ui_pages_pages, :undo_history, :json
6
+ add_column :keystone_ui_pages_pages, :undone_changes, :json
7
+ end
8
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ class Catalog
6
+ Element = Data.define(:key, :name, :component, :fields, :body, :icon, :group, :align)
7
+
8
+ def initialize
9
+ @elements = {}
10
+ end
11
+
12
+ def register(key:, name:, component:, fields:, body: nil, icon: nil, group: nil, align: nil)
13
+ @elements[key.to_sym] = Element.new(key: key.to_sym, name: name, component: component, fields: fields, body: body, icon: icon, group: group, align: align)
14
+ end
15
+
16
+ def leave_out(*keys)
17
+ keys.each { |key| @elements.delete(key.to_sym) }
18
+ end
19
+
20
+ def elements
21
+ @elements.values
22
+ end
23
+
24
+ def find(key)
25
+ @elements[key.to_sym]
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module KeystoneUi
6
+ module Pages
7
+ module Document
8
+ SCHEMA_VERSION = 1
9
+
10
+ module_function
11
+
12
+ def blank
13
+ { "schema_version" => SCHEMA_VERSION, "sections" => [ section ] }
14
+ end
15
+
16
+ def section
17
+ { "id" => SecureRandom.uuid, "rows" => [ row ] }
18
+ end
19
+
20
+ def row
21
+ { "id" => SecureRandom.uuid, "columns" => [ column ] }
22
+ end
23
+
24
+ def column
25
+ { "id" => SecureRandom.uuid, "elements" => [] }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "keystone_ui"
5
+ require "keystone_ui-react"
6
+ require "keystone_ui/react/mount_helper"
7
+
8
+ module KeystoneUi
9
+ module Pages
10
+ class Engine < ::Rails::Engine
11
+ isolate_namespace KeystoneUi::Pages
12
+
13
+ initializer "keystone_ui_pages.host_helper" do
14
+ ActiveSupport.on_load(:action_view) { include KeystoneUi::Pages::HostHelper }
15
+ end
16
+
17
+ initializer "keystone_ui_pages.tailwind" do
18
+ KeystoneUi.configuration.tailwind_sources << root.join("app/views/**/*.erb").to_s
19
+ KeystoneUi.configuration.tailwind_sources << root.join("app/helpers/**/*.rb").to_s
20
+ KeystoneUi.configuration.tailwind_sources << root.join("app/javascript/**/*.{js,jsx}").to_s
21
+ KeystoneUi.configuration.tailwind_sources << root.join("lib/keystone_ui/pages/**/*.rb").to_s
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ module KeystoneElements
6
+ module_function
7
+
8
+ def register(catalog)
9
+ catalog.register(key: :button, name: "Button", component: :ui_button, icon: :cursor, group: "Basics", align: "start",
10
+ fields: [ { key: :label, label: "Label", default: "Button" }, { key: :href, label: "Address" } ])
11
+ catalog.register(key: :progress, name: "Progress", component: :ui_progress, icon: :progress, group: "Data",
12
+ fields: [ { key: :value, label: "Value", kind: :number, default: "0" }, { key: :max, label: "Out of", kind: :number, default: "100" }, { key: :label, label: "Label", kind: :text, default: "Progress" } ])
13
+ catalog.register(key: :alert, name: "Alert", component: :ui_alert, icon: :alert, group: "Text",
14
+ fields: [ { key: :message, label: "Message", kind: :long_text, default: "Something worth knowing." }, { key: :title, label: "Title", kind: :text },
15
+ { key: :type, label: "Type", kind: :one_of, options: %w[info success warning error], default: "info" },
16
+ { key: :dismissible, label: "Can be dismissed", kind: :yes_or_no } ])
17
+ catalog.register(key: :code, name: "Code", component: :ui_code, icon: :code, group: "Text", body: :code,
18
+ fields: [ { key: :code, label: "Code", kind: :long_text, default: "puts \"Hello\"" }, { key: :language, label: "Language", kind: :text },
19
+ { key: :caption, label: "Caption", kind: :text } ])
20
+ catalog.register(key: :heading, name: "Heading", component: :ui_heading, icon: :heading, group: "Text",
21
+ fields: [ { key: :text, label: "Text", kind: :text, default: "Heading" },
22
+ { key: :size, label: "Size", kind: :one_of, options: %w[sm md lg], default: "md" },
23
+ { key: :note, label: "Note", kind: :text } ])
24
+ catalog.register(key: :label, name: "Label", component: :ui_caption, icon: :tag, group: "Text",
25
+ fields: [ { key: :text, label: "Text", kind: :text, default: "Label" } ])
26
+ catalog.register(key: :paragraph, name: "Paragraph", component: :ui_paragraph, icon: :text, group: "Text",
27
+ fields: [ { key: :text, label: "Text", kind: :long_text, default: "Write the text of this paragraph here." } ])
28
+ catalog.register(key: :hero, name: "Hero", component: :ui_hero, icon: :heading, group: "Text",
29
+ fields: [ { key: :title, label: "Title", kind: :text, default: "Headline" },
30
+ { key: :subtitle, label: "Subtitle", kind: :long_text },
31
+ { key: :badge, label: "Badge", kind: :text },
32
+ { key: :layout, label: "Layout", kind: :one_of, options: %w[split centered], default: "split" },
33
+ { key: :height, label: "Height", kind: :one_of, options: %w[content tall full_screen], default: "tall",
34
+ classes: { "content" => "[&_.ks-hero]:min-h-0", "tall" => "[&_.ks-hero]:min-h-[28rem]", "full_screen" => "[&_.ks-hero]:min-h-screen" } } ])
35
+ catalog.register(key: :cta_banner, name: "Call to action", component: :ui_cta_banner, icon: :megaphone, group: "Content", body: :text,
36
+ fields: [ { key: :title, label: "Title", kind: :text, default: "Ready to start?" },
37
+ { key: :subtitle, label: "Subtitle", kind: :text },
38
+ { key: :text, label: "Text", kind: :long_text } ])
39
+ catalog.register(key: :card, name: "Card", component: :ui_card, icon: :card, group: "Content",
40
+ fields: [ { key: :title, label: "Title", kind: :text, default: "Card title" },
41
+ { key: :summary, label: "Summary", kind: :long_text },
42
+ { key: :link, label: "Link", kind: :address, default: "#" },
43
+ { key: :cta, label: "Link text", kind: :text, default: "Read more" } ])
44
+ catalog.register(key: :stat_card, name: "Stat", component: :ui_stat_card, icon: :chart, group: "Data",
45
+ fields: [ { key: :label, label: "Label", kind: :text, default: "Label" },
46
+ { key: :value, label: "Value", kind: :text, default: "0" },
47
+ { key: :suffix, label: "Suffix", kind: :text },
48
+ { key: :variant, label: "Tone", kind: :one_of, options: %w[neutral success danger warning info], default: "neutral" },
49
+ { key: :note, label: "Note", kind: :text },
50
+ { key: :plain, label: "Plain", kind: :yes_or_no },
51
+ { key: :fallback, label: "When missing", kind: :text, default: "N/A" } ])
52
+ catalog.register(key: :figure, name: "Figure", component: :ui_figure, icon: :number, group: "Data",
53
+ fields: [ { key: :text, label: "Text", kind: :text, default: "0" },
54
+ { key: :tone, label: "Tone", kind: :one_of, options: %w[neutral success danger], default: "neutral" } ])
55
+ catalog.register(key: :info, name: "Info", component: :ui_info, icon: :info, group: "Text", body: :detail,
56
+ fields: [ { key: :summary, label: "Summary", kind: :text, default: "More information" },
57
+ { key: :detail, label: "Detail", kind: :long_text } ])
58
+ catalog.register(key: :badge, name: "Badge", component: :ui_badge, icon: :tag, group: "Basics", align: "start",
59
+ fields: [ { key: :label, label: "Label", kind: :text, default: "Badge" },
60
+ { key: :variant, label: "Tone", kind: :one_of, options: %w[neutral success danger warning info], default: "neutral" } ])
61
+ catalog.register(key: :copy_button, name: "Copy button", component: :ui_copy_button, icon: :copy, group: "Basics", align: "start",
62
+ fields: [ { key: :text, label: "Text to copy", kind: :text },
63
+ { key: :label, label: "Label", kind: :text, default: "Copy" } ])
64
+ catalog.register(key: :feature_grid, name: "Feature grid", component: :ui_feature_grid, icon: :card, group: "Content",
65
+ fields: [ { key: :title, label: "Title", kind: :text, default: "Why teams choose us" },
66
+ { key: :subtitle, label: "Subtitle", kind: :text },
67
+ { key: :features, label: "Features", kind: :list,
68
+ entry: [ { key: :title, label: "Title", kind: :text }, { key: :description, label: "Description", kind: :long_text },
69
+ { key: :icon, label: "Icon", kind: :text, hidden: true,
70
+ default: '<svg viewBox="0 0 24 24" class="size-6 fill-none stroke-current stroke-2"><path d="M5 12l5 5L20 7"/></svg>' } ],
71
+ default: [ { "title" => "Fast", "description" => "Pages load quickly." },
72
+ { "title" => "Clear", "description" => "Every page reads plainly." },
73
+ { "title" => "Yours", "description" => "Built from your own components." } ] } ])
74
+ catalog.register(key: :accordion, name: "Accordion", component: :ui_accordion, icon: :code, group: "Text",
75
+ fields: [ { key: :items, label: "Questions", kind: :list,
76
+ entry: [ { key: :question, label: "Question", kind: :text }, { key: :answer, label: "Answer", kind: :long_text } ],
77
+ default: [ { "question" => "What is this?", "answer" => "A question visitors often ask." },
78
+ { "question" => "How does it work?", "answer" => "The answer opens below its question." } ] } ])
79
+ catalog.register(key: :data_table, name: "Table", component: :ui_data_table, icon: :card, group: "Data",
80
+ fields: [ { key: :items, label: "Rows", kind: :data,
81
+ default: [ { "name" => "First row", "status" => "Open" }, { "name" => "Second row", "status" => "Closed" } ] },
82
+ { key: :columns, label: "Columns", kind: :list, as: :pairs,
83
+ entry: [ { key: :key, label: "Key", kind: :text }, { key: :label, label: "Heading", kind: :text },
84
+ { key: :align, label: "Align", kind: :one_of, options: %w[start end] } ],
85
+ default: [ { "key" => "name", "label" => "Name" }, { "key" => "status", "label" => "Status" } ] },
86
+ { key: :group_by, label: "Group rows by key", kind: :text, as: :key },
87
+ { key: :group_totals, label: "Group subtotals", kind: :data },
88
+ { key: :total, label: "Total row", kind: :data } ])
89
+ catalog.register(key: :value_list, name: "Label and value list", component: :ui_value_list, icon: :list, group: "Data",
90
+ fields: [ { key: :rows, label: "Rows", kind: :list,
91
+ entry: [ { key: :label, label: "Label", kind: :text }, { key: :value, label: "Value", kind: :text },
92
+ { key: :note, label: "Note", kind: :text }, { key: :total, label: "Total", kind: :yes_or_no },
93
+ { key: :fallback, label: "When missing", kind: :text, default: "N/A" } ],
94
+ default: [ { "label" => "Prepared by", "value" => "Your team" },
95
+ { "label" => "Prepared for", "value" => "Your client" },
96
+ { "label" => "Issued", "value" => "1 October" } ] },
97
+ { key: :align, label: "Values", kind: :one_of, options: %w[start end], default: "start" } ])
98
+ catalog.register(key: :bar_chart, name: "Bar chart", component: :ui_bar_chart, icon: :bars, group: "Data",
99
+ fields: [ { key: :bars, label: "Bars", kind: :data,
100
+ default: [ { "label" => "Mon", "value" => "3" }, { "label" => "Tue", "value" => "5" }, { "label" => "Wed", "value" => "2" },
101
+ { "label" => "Sat", "value" => "1", "shaded" => true } ] },
102
+ { key: :show_values, label: "Show values", kind: :yes_or_no, default: "true" } ])
103
+ catalog.register(key: :line_chart, name: "Line chart", component: :ui_line_chart, icon: :chart, group: "Data",
104
+ fields: [ { key: :series, label: "Series", kind: :data, default: [ { "name" => "Visits", "data" => [ 3, 5, 4, 7 ] } ] },
105
+ { key: :labels, label: "Labels", kind: :data, default: %w[Mon Tue Wed Thu] } ])
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "securerandom"
6
+ require "keystone_ui/pages"
7
+ require "keystone_ui/pages/document"
8
+ require "keystone_ui/pages/values"
9
+
10
+ module KeystoneUi
11
+ module Pages
12
+ class InvalidDocument < StandardError; end
13
+
14
+ module Operations
15
+ WIDTHS = %w[1/1 3/4 2/3 1/2 1/3 1/4].freeze
16
+ ARRANGEMENTS = {
17
+ section: { "spacing" => %w[sm md lg] },
18
+ row: { "gap" => %w[sm md lg xl] },
19
+ column: { "wide" => WIDTHS, "narrow" => WIDTHS, "align" => %w[top center bottom] },
20
+ element: { "align" => %w[stretch start center end] }
21
+ }.freeze
22
+ DEFAULTS = {
23
+ section: { "spacing" => "md" },
24
+ row: { "gap" => "md" },
25
+ column: { "narrow" => "1/1", "align" => "top" },
26
+ element: { "align" => "stretch" }
27
+ }.freeze
28
+ SHARES = { 1 => "1/1", 2 => "1/2", 3 => "1/3", 4 => "1/4" }.freeze
29
+
30
+ module_function
31
+
32
+ def add(document, key, column_id:, catalog: Pages.catalog)
33
+ defined(catalog, key)
34
+ copied(document).tap do |changed|
35
+ column(changed, column_id)["elements"] << { "id" => SecureRandom.uuid, "key" => key.to_s, "fields" => {} }
36
+ end
37
+ end
38
+
39
+ def add_section(document)
40
+ copied(document).tap { |changed| changed["sections"] << Document.section }
41
+ end
42
+
43
+ def add_row(document, section_id:)
44
+ copied(document).tap { |changed| node(changed, section_id)["rows"] << Document.row }
45
+ end
46
+
47
+ def add_column(document, row_id:)
48
+ copied(document).tap { |changed| node(changed, row_id)["columns"] << Document.column }
49
+ end
50
+
51
+ def arrange(document, node_id:, setting:, value:)
52
+ offered_arrangement(kind(document, node_id), setting.to_s, value)
53
+ copied(document).tap do |changed|
54
+ node(changed, node_id)[setting.to_s] = value
55
+ fitting(changed, setting.to_s) if %w[wide narrow].include?(setting.to_s)
56
+ end
57
+ end
58
+
59
+ def fitting(document, screen)
60
+ document["sections"].each.with_index(1) do |section, section_number|
61
+ section["rows"].each.with_index(1) do |row, row_number|
62
+ next if row["columns"].sum { |column| Rational(column[screen] || "0") } <= 1
63
+
64
+ raise InvalidDocument, "Row #{row_number} of section #{section_number} is wider than the page on #{screen} screens"
65
+ end
66
+ end
67
+ end
68
+
69
+ def offered_arrangement(kind, setting, value)
70
+ return if ARRANGEMENTS.fetch(kind).fetch(setting, []).include?(value)
71
+
72
+ raise InvalidDocument, "A #{kind}'s #{setting} cannot be #{value}"
73
+ end
74
+
75
+ def kind(document, node_id)
76
+ sections = document["sections"]
77
+ return :section if sections.any? { |section| section["id"] == node_id }
78
+ return :row if sections.flat_map { |section| section["rows"] }.any? { |row| row["id"] == node_id }
79
+ return :column if column(document, node_id)
80
+
81
+ :element
82
+ end
83
+
84
+ def move(document, node_id:, into:, position:)
85
+ present(document, node_id)
86
+ outside(document, node_id, into)
87
+ copied(document).tap do |changed|
88
+ moved = node(changed, node_id)
89
+ parent_list(changed, node_id).delete(moved)
90
+ children(changed, into).insert(position.to_i, moved)
91
+ end
92
+ end
93
+
94
+ def outside(document, node_id, into)
95
+ return unless inside(node(document, node_id)).any? { |child| child["id"] == into }
96
+
97
+ raise InvalidDocument, "A #{kind(document, node_id)} cannot be moved into something inside it"
98
+ end
99
+
100
+ def inside(node)
101
+ children = node.values_at("rows", "columns", "elements").compact.flatten
102
+ children + children.flat_map { |child| inside(child) }
103
+ end
104
+
105
+ def present(document, node_id)
106
+ node(document, node_id) or raise InvalidDocument, "Nothing in this page has the id #{node_id}"
107
+ end
108
+
109
+ def parent_list(document, node_id)
110
+ lists(document).find { |list| list.any? { |child| child["id"] == node_id } }
111
+ end
112
+
113
+ def lists(document)
114
+ sections = document["sections"]
115
+ rows = sections.flat_map { |section| section["rows"] }
116
+ [ sections ] + sections.map { |section| section["rows"] } + rows.map { |row| row["columns"] } + columns(document).map { |column| column["elements"] }
117
+ end
118
+
119
+ def children(document, parent_id)
120
+ return document["sections"] if parent_id.nil?
121
+
122
+ parent = node(document, parent_id)
123
+ parent["rows"] || parent["columns"] || parent["elements"]
124
+ end
125
+
126
+ def duplicate(document, node_id:)
127
+ present(document, node_id)
128
+ copied(document).tap do |changed|
129
+ siblings = parent_list(changed, node_id)
130
+ original = siblings.find { |child| child["id"] == node_id }
131
+ siblings.insert(siblings.index(original) + 1, renewed(copied(original)))
132
+ end
133
+ end
134
+
135
+ def renewed(node)
136
+ node.merge("id" => SecureRandom.uuid).tap do |copy|
137
+ %w[rows columns elements].each { |list| copy[list] = copy[list].map { |child| renewed(child) } if copy.key?(list) }
138
+ end
139
+ end
140
+
141
+ def remove(document, node_id:)
142
+ present(document, node_id)
143
+ held = held_elements(node(document, node_id))
144
+ raise InvalidDocument, "This #{kind(document, node_id)} holds #{held} #{held == 1 ? 'element' : 'elements'}, so it cannot be removed" if held.positive?
145
+
146
+ copied(document).tap { |changed| parent_list(changed, node_id).reject! { |child| child["id"] == node_id } }
147
+ end
148
+
149
+ def held_elements(node)
150
+ return 0 if node.key?("key")
151
+ return node["elements"].size if node.key?("elements")
152
+
153
+ (node["rows"] || node["columns"]).sum { |child| held_elements(child) }
154
+ end
155
+
156
+ def show_when(document, node_id:, name:, equals:)
157
+ raise InvalidDocument, "A column cannot be shown or hidden by a value" if kind(document, node_id) == :column
158
+ return copied(document).tap { |changed| node(changed, node_id).delete("show_when") } if name.blank?
159
+
160
+ raise InvalidDocument, "No value is named #{name}" unless Values.label(name)
161
+
162
+ copied(document).tap do |changed|
163
+ node(changed, node_id)["show_when"] = { "value" => name.to_s, "equals" => equals.to_s }
164
+ end
165
+ end
166
+
167
+ def bind(document, element_id:, field:, name:, fallback: nil, catalog: Pages.catalog)
168
+ raise InvalidDocument, "No value is named #{name}" unless Values.label(name)
169
+
170
+ copied(document).tap do |changed|
171
+ element = element(changed, element_id)
172
+ offered(defined(catalog, element["key"]), field)
173
+ element["fields"][field.to_s] = { "value" => name.to_s, "fallback" => fallback }
174
+ end
175
+ end
176
+
177
+ def set(document, element_id:, field:, value:, catalog: Pages.catalog)
178
+ copied(document).tap do |changed|
179
+ element = element(changed, element_id)
180
+ offered(defined(catalog, element["key"]), field)
181
+ element["fields"][field.to_s] = value
182
+ end
183
+ end
184
+
185
+ def check(document, catalog: Pages.catalog)
186
+ elements(document).each do |element|
187
+ definition = defined(catalog, element["key"])
188
+ element["fields"].each_key { |field| offered(definition, field) }
189
+ end
190
+ document
191
+ end
192
+
193
+ def digest(document)
194
+ Digest::SHA256.hexdigest(JSON.generate(sorted(document)))
195
+ end
196
+
197
+ def sorted(value)
198
+ case value
199
+ when Hash then value.sort_by { |key, _| key.to_s }.to_h { |key, nested| [ key, sorted(nested) ] }
200
+ when Array then value.map { |nested| sorted(nested) }
201
+ else value
202
+ end
203
+ end
204
+
205
+ def defined(catalog, key)
206
+ catalog.find(key) or raise InvalidDocument, "No element is registered as #{key}"
207
+ end
208
+
209
+ def offered(definition, field)
210
+ return if definition.fields.any? { |offered| offered[:key].to_s == field.to_s }
211
+
212
+ raise InvalidDocument, "The #{definition.key} element has no #{field} field"
213
+ end
214
+
215
+ def copied(document)
216
+ JSON.parse(JSON.generate(document))
217
+ end
218
+
219
+ def nodes(document)
220
+ sections = document["sections"]
221
+ rows = sections.flat_map { |section| section["rows"] }
222
+ sections + rows + columns(document) + elements(document)
223
+ end
224
+
225
+ def node(document, node_id)
226
+ nodes(document).find { |node| node["id"] == node_id }
227
+ end
228
+
229
+ def columns(document)
230
+ document["sections"].flat_map { |section| section["rows"] }.flat_map { |row| row["columns"] }
231
+ end
232
+
233
+ def column(document, column_id)
234
+ columns(document).find { |column| column["id"] == column_id }
235
+ end
236
+
237
+ def elements(document)
238
+ columns(document).flat_map { |column| column["elements"] }
239
+ end
240
+
241
+ def element(document, element_id)
242
+ elements(document).find { |element| element["id"] == element_id }
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ module PdfDocument
6
+ module_function
7
+
8
+ def html(version, values: {})
9
+ KeystoneUi::Pages::PagesController.render(template: "keystone_ui/pages/pdfs/show", layout: false, assigns: { version: version, values: values, stylesheet: stylesheet })
10
+ end
11
+
12
+ def stylesheet
13
+ Rails.application.assets.load_path.find(Pages.pdf_stylesheet)&.content.to_s.dup.force_encoding(Encoding::UTF_8)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "keystone_ui/pages/operations"
4
+ require "keystone_ui/pages/pdf_document"
5
+ require "keystone_ui/pages/printer"
6
+
7
+ module KeystoneUi
8
+ module Pages
9
+ module Pdfs
10
+ module_function
11
+
12
+ def for(page, values: {})
13
+ version = page.versions.live.first
14
+ digest = Operations.digest(values.to_h.stringify_keys)
15
+ Pdf.find_by(version: version, values_digest: digest) || Pdf.create!(version: version, values_digest: digest,
16
+ file: { io: StringIO.new(Printer.pdf(PdfDocument.html(version, values: values))), filename: "#{page.slug}.pdf", content_type: "application/pdf" })
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tmpdir"
5
+ require "keystone_ui/pages"
6
+
7
+ module KeystoneUi
8
+ module Pages
9
+ class PrintFailed < StandardError; end
10
+
11
+ module Printer
12
+ USUAL_PLACES = [
13
+ "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser",
14
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium"
15
+ ].freeze
16
+
17
+ module_function
18
+
19
+ def pdf(html)
20
+ Dir.mktmpdir do |folder|
21
+ page = File.join(folder, "page.html")
22
+ printed = File.join(folder, "page.pdf")
23
+ File.write(page, html)
24
+ printed_by_chrome(page, printed)
25
+ File.binread(printed)
26
+ end
27
+ end
28
+
29
+ def chrome
30
+ Pages.chrome_path.presence || ENV["CHROME_BIN"].presence || USUAL_PLACES.find { |place| File.executable?(place) }
31
+ end
32
+
33
+ def printed_by_chrome(page, printed)
34
+ raise PrintFailed, "No Chrome or Chromium was found to print the PDF. Install one, or name it with KeystoneUi::Pages.chrome_path or CHROME_BIN." if chrome.nil?
35
+
36
+ _output, errors, _status = Open3.capture3(chrome.to_s, "--headless=new", "--disable-gpu", "--use-mock-keychain", "--password-store=basic", "--no-pdf-header-footer", "--print-to-pdf=#{printed}", "file://#{page}")
37
+ raise PrintFailed, "Chrome at #{chrome} could not print the page: #{errors.strip}" unless File.size?(printed)
38
+ rescue SystemCallError => error
39
+ raise PrintFailed, "Chrome at #{chrome} could not be started: #{error.message}"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ module Values
6
+ class << self
7
+ attr_writer :names
8
+ attr_accessor :provider, :chooser
9
+
10
+ def names
11
+ @names ||= {}
12
+ end
13
+
14
+ def label(name)
15
+ names[name.to_s]
16
+ end
17
+
18
+ def samples_for(page)
19
+ chooser ? chooser.call(page).to_a : []
20
+ end
21
+
22
+ def sample(page, choice)
23
+ provider ? provider.call(page, choice).to_h.stringify_keys : {}
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ VERSION = "0.1.0"
6
+ end
7
+ end