journeybook 0.1.6.pre.alpha

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 (134) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +354 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/images/journeybook/.keep +0 -0
  7. data/app/assets/images/journeybook/logo.svg +5 -0
  8. data/app/assets/javascripts/journeybook/sortable.js +96 -0
  9. data/app/assets/stylesheets/journeybook/application.css +1556 -0
  10. data/app/controllers/concerns/.keep +0 -0
  11. data/app/controllers/concerns/journeybook/public_pages.rb +31 -0
  12. data/app/controllers/journeybook/admin/ai_pages_controller.rb +65 -0
  13. data/app/controllers/journeybook/admin/ai_style_prompts_controller.rb +51 -0
  14. data/app/controllers/journeybook/admin/components_controller.rb +57 -0
  15. data/app/controllers/journeybook/admin/dashboard_controller.rb +13 -0
  16. data/app/controllers/journeybook/admin/media_assets_controller.rb +59 -0
  17. data/app/controllers/journeybook/admin/navigation_items_controller.rb +91 -0
  18. data/app/controllers/journeybook/admin/navigation_menus_controller.rb +59 -0
  19. data/app/controllers/journeybook/admin/pages_controller.rb +304 -0
  20. data/app/controllers/journeybook/admin/redirects_controller.rb +51 -0
  21. data/app/controllers/journeybook/admin/sites_controller.rb +51 -0
  22. data/app/controllers/journeybook/application_controller.rb +52 -0
  23. data/app/controllers/journeybook/form_submissions_controller.rb +55 -0
  24. data/app/helpers/journeybook/application_helper.rb +4 -0
  25. data/app/helpers/journeybook/navigation_helper.rb +11 -0
  26. data/app/javascript/journeybook/application.js +5 -0
  27. data/app/javascript/journeybook/component_search_controller.js +13 -0
  28. data/app/jobs/journeybook/application_job.rb +4 -0
  29. data/app/models/concerns/.keep +0 -0
  30. data/app/models/journeybook/ai_style_prompt.rb +6 -0
  31. data/app/models/journeybook/application_record.rb +5 -0
  32. data/app/models/journeybook/media_asset.rb +26 -0
  33. data/app/models/journeybook/navigation_item.rb +45 -0
  34. data/app/models/journeybook/navigation_menu.rb +23 -0
  35. data/app/models/journeybook/page.rb +96 -0
  36. data/app/models/journeybook/redirect.rb +31 -0
  37. data/app/models/journeybook/site.rb +62 -0
  38. data/app/services/journeybook/ai/markdown_generator.rb +15 -0
  39. data/app/services/journeybook/ai/openai_client.rb +54 -0
  40. data/app/services/journeybook/ai/prompt_builder.rb +154 -0
  41. data/app/services/journeybook/ai/template_validator.rb +17 -0
  42. data/app/services/journeybook/component_parser.rb +53 -0
  43. data/app/services/journeybook/component_renderer.rb +108 -0
  44. data/app/services/journeybook/component_tag_updater.rb +132 -0
  45. data/app/services/journeybook/component_validator.rb +111 -0
  46. data/app/services/journeybook/form_mailer.rb +69 -0
  47. data/app/services/journeybook/html_sanitizer.rb +46 -0
  48. data/app/services/journeybook/letter_opener_client.rb +14 -0
  49. data/app/services/journeybook/markdown_renderer.rb +29 -0
  50. data/app/services/journeybook/page_renderer.rb +58 -0
  51. data/app/services/journeybook/page_resolver.rb +55 -0
  52. data/app/services/journeybook/postmark_client.rb +48 -0
  53. data/app/services/journeybook/storage/s3_client.rb +96 -0
  54. data/app/views/journeybook/admin/ai_pages/_draft_form.html.erb +73 -0
  55. data/app/views/journeybook/admin/ai_pages/new.html.erb +43 -0
  56. data/app/views/journeybook/admin/ai_style_prompts/_form.html.erb +16 -0
  57. data/app/views/journeybook/admin/ai_style_prompts/edit.html.erb +2 -0
  58. data/app/views/journeybook/admin/ai_style_prompts/index.html.erb +26 -0
  59. data/app/views/journeybook/admin/ai_style_prompts/new.html.erb +2 -0
  60. data/app/views/journeybook/admin/components/_component.html.erb +17 -0
  61. data/app/views/journeybook/admin/components/_reference.html.erb +9 -0
  62. data/app/views/journeybook/admin/components/index.html.erb +46 -0
  63. data/app/views/journeybook/admin/components/preview.html.erb +13 -0
  64. data/app/views/journeybook/admin/dashboard/show.html.erb +34 -0
  65. data/app/views/journeybook/admin/media_assets/_reference.html.erb +16 -0
  66. data/app/views/journeybook/admin/media_assets/index.html.erb +43 -0
  67. data/app/views/journeybook/admin/media_assets/new.html.erb +41 -0
  68. data/app/views/journeybook/admin/navigation_items/_form.html.erb +46 -0
  69. data/app/views/journeybook/admin/navigation_items/edit.html.erb +2 -0
  70. data/app/views/journeybook/admin/navigation_items/new.html.erb +2 -0
  71. data/app/views/journeybook/admin/navigation_menus/_form.html.erb +10 -0
  72. data/app/views/journeybook/admin/navigation_menus/edit.html.erb +38 -0
  73. data/app/views/journeybook/admin/navigation_menus/index.html.erb +28 -0
  74. data/app/views/journeybook/admin/navigation_menus/new.html.erb +2 -0
  75. data/app/views/journeybook/admin/pages/_collection_field.html.erb +58 -0
  76. data/app/views/journeybook/admin/pages/_form.html.erb +77 -0
  77. data/app/views/journeybook/admin/pages/_image_picker_modal.html.erb +23 -0
  78. data/app/views/journeybook/admin/pages/_table.html.erb +37 -0
  79. data/app/views/journeybook/admin/pages/_url_field.html.erb +25 -0
  80. data/app/views/journeybook/admin/pages/edit.html.erb +547 -0
  81. data/app/views/journeybook/admin/pages/errors.html.erb +25 -0
  82. data/app/views/journeybook/admin/pages/index.html.erb +52 -0
  83. data/app/views/journeybook/admin/pages/new.html.erb +27 -0
  84. data/app/views/journeybook/admin/pages/new_component.html.erb +70 -0
  85. data/app/views/journeybook/admin/pages/public_preview.html.erb +27 -0
  86. data/app/views/journeybook/admin/pages/source.html.erb +23 -0
  87. data/app/views/journeybook/admin/redirects/_form.html.erb +9 -0
  88. data/app/views/journeybook/admin/redirects/edit.html.erb +2 -0
  89. data/app/views/journeybook/admin/redirects/index.html.erb +15 -0
  90. data/app/views/journeybook/admin/redirects/new.html.erb +2 -0
  91. data/app/views/journeybook/admin/shared/_drag_handle.html.erb +3 -0
  92. data/app/views/journeybook/admin/shared/_errors.html.erb +10 -0
  93. data/app/views/journeybook/admin/sites/_form.html.erb +12 -0
  94. data/app/views/journeybook/admin/sites/edit.html.erb +2 -0
  95. data/app/views/journeybook/admin/sites/index.html.erb +26 -0
  96. data/app/views/journeybook/admin/sites/new.html.erb +2 -0
  97. data/app/views/layouts/journeybook/application.html.erb +57 -0
  98. data/config/importmap.rb +3 -0
  99. data/config/routes.rb +39 -0
  100. data/db/migrate/20260627000001_create_journeybook_sites.rb +13 -0
  101. data/db/migrate/20260627000002_create_journeybook_pages.rb +23 -0
  102. data/db/migrate/20260627000003_create_journeybook_redirects.rb +13 -0
  103. data/db/migrate/20260628000004_add_context_fields_to_journeybook_sites.rb +38 -0
  104. data/db/migrate/20260628000005_create_journeybook_media_assets.rb +24 -0
  105. data/db/migrate/20260629000006_create_journeybook_navigation.rb +28 -0
  106. data/db/migrate/20260731000007_add_open_in_new_window_to_journeybook_navigation_items.rb +5 -0
  107. data/db/migrate/20260731000008_create_journeybook_ai_style_prompts.rb +12 -0
  108. data/db/migrate/20260801000009_add_blog_fields_to_journeybook_pages.rb +9 -0
  109. data/db/migrate/20260801000010_add_knowledge_base_fields_to_journeybook_pages.rb +8 -0
  110. data/db/migrate/20260801000011_add_position_to_journeybook_pages.rb +6 -0
  111. data/lib/generators/journeybook/install/install_generator.rb +72 -0
  112. data/lib/generators/journeybook/install/templates/add_blog_fields_to_journeybook_pages.rb +9 -0
  113. data/lib/generators/journeybook/install/templates/add_context_fields_to_journeybook_sites.rb +38 -0
  114. data/lib/generators/journeybook/install/templates/add_knowledge_base_fields_to_journeybook_pages.rb +8 -0
  115. data/lib/generators/journeybook/install/templates/add_open_in_new_window_to_journeybook_navigation_items.rb +5 -0
  116. data/lib/generators/journeybook/install/templates/add_position_to_journeybook_pages.rb +6 -0
  117. data/lib/generators/journeybook/install/templates/create_journeybook_ai_style_prompts.rb +12 -0
  118. data/lib/generators/journeybook/install/templates/create_journeybook_media_assets.rb +24 -0
  119. data/lib/generators/journeybook/install/templates/create_journeybook_navigation.rb +28 -0
  120. data/lib/generators/journeybook/install/templates/create_journeybook_pages.rb +33 -0
  121. data/lib/generators/journeybook/install/templates/create_journeybook_redirects.rb +13 -0
  122. data/lib/generators/journeybook/install/templates/create_journeybook_sites.rb +13 -0
  123. data/lib/generators/journeybook/install/templates/journeybook.rb +42 -0
  124. data/lib/generators/journeybook/install/templates/pages_controller.rb +3 -0
  125. data/lib/generators/journeybook/install/templates/show.html.erb +10 -0
  126. data/lib/journeybook/component_registry.rb +136 -0
  127. data/lib/journeybook/configuration.rb +55 -0
  128. data/lib/journeybook/engine.rb +19 -0
  129. data/lib/journeybook/page_template_registry.rb +67 -0
  130. data/lib/journeybook/public_page_constraint.rb +23 -0
  131. data/lib/journeybook/version.rb +3 -0
  132. data/lib/journeybook.rb +62 -0
  133. data/lib/tasks/journeybook_tasks.rake +8 -0
  134. metadata +266 -0
@@ -0,0 +1,70 @@
1
+ <% content_for :main_class, "jb-main-wide" %>
2
+
3
+ <div class="jb-component-add-page">
4
+ <aside class="jb-card jb-component-picker" aria-label="Available components">
5
+ <p class="jb-eyebrow">Add component</p>
6
+ <h1 class="jb-card-title"><%= @page.title %></h1>
7
+
8
+ <% if @components.any? %>
9
+ <div data-controller="journeybook-component-search">
10
+ <label class="jb-component-search">
11
+ Search components
12
+ <input type="search" placeholder="Search" autocomplete="off" autofocus data-journeybook-component-search-target="input" data-action="input->journeybook-component-search#filter">
13
+ </label>
14
+ <nav class="jb-component-nav">
15
+ <% @components.each do |component| %>
16
+ <div class="jb-component-nav-item" data-journeybook-component-search-target="item" data-component-search-name="<%= component.name %>">
17
+ <%= link_to new_component_admin_page_path(@page, site_id: @page.site_id, component: component.name), class: ("is-active" if component == @selected_component), "aria-current": ("page" if component == @selected_component) do %>
18
+ <strong><%= component.name %></strong>
19
+ <% if component.metadata[:description].present? %>
20
+ <span><%= component.metadata[:description] %></span>
21
+ <% end %>
22
+ <% end %>
23
+ <%= button_to "+", add_component_admin_page_path(@page, site_id: @page.site_id), method: :post, params: { component_name: component.name }, class: "jb-component-add-button", title: "Add #{component.name}", aria: { label: "Add #{component.name}" } %>
24
+ </div>
25
+ <% end %>
26
+ </nav>
27
+ </div>
28
+ <% else %>
29
+ <p class="jb-subtitle">No components are registered for this site.</p>
30
+ <% end %>
31
+
32
+ <div class="jb-component-add-sidebar-footer">
33
+ <%= link_to "Back to editor", edit_admin_page_path(@page, site_id: @page.site_id, tab: "layout"), class: "jb-button jb-button-secondary" %>
34
+ </div>
35
+ </aside>
36
+
37
+ <main class="jb-card jb-component-add-preview">
38
+ <% if @selected_component %>
39
+ <header class="jb-header">
40
+ <div>
41
+ <p class="jb-eyebrow">Preview</p>
42
+ <h2 class="jb-title"><code>{{ <%= @selected_component.name %> }}</code></h2>
43
+ <% if @selected_component.metadata[:description].present? %>
44
+ <p class="jb-subtitle"><%= @selected_component.metadata[:description] %></p>
45
+ <% end %>
46
+ </div>
47
+ </header>
48
+
49
+ <iframe class="jb-preview jb-component-add-preview-frame" src="<%= admin_component_preview_path(@selected_component.name, site_id: @page.site_id) %>" title="<%= @selected_component.name %> preview"></iframe>
50
+
51
+ <% else %>
52
+ <p class="jb-subtitle">Select a component to preview it.</p>
53
+ <% end %>
54
+ </main>
55
+ </div>
56
+
57
+ <%= javascript_tag nonce: true do %>
58
+ (() => {
59
+ const search = document.querySelector("[data-component-search]");
60
+ const items = document.querySelectorAll("[data-component-search-item]");
61
+ if (!search || !items.length) return;
62
+
63
+ search.addEventListener("input", () => {
64
+ const query = search.value.trim().toLowerCase();
65
+ items.forEach((item) => {
66
+ item.hidden = Boolean(query) && !item.dataset.componentSearchItem.toLowerCase().includes(query);
67
+ });
68
+ });
69
+ })();
70
+ <% end %>
@@ -0,0 +1,27 @@
1
+ <% content_for :title, @journeybook_page.meta_title.presence || @journeybook_page.title %>
2
+ <% content_for :head do %>
3
+ <% if @journeybook_page.meta_description.present? %>
4
+ <meta name="description" content="<%= @journeybook_page.meta_description %>">
5
+ <% end %>
6
+ <% end %>
7
+
8
+ <article class="journeybook-page"<% if params[:component].present? %> style="padding-top: 6rem"<% end %>>
9
+ <%= @journeybook_html.html_safe %>
10
+ </article>
11
+
12
+ <%= javascript_tag nonce: true do %>
13
+ window.addEventListener("message", (event) => {
14
+ if (event.origin !== window.location.origin || event.data?.type !== "journeybook:scroll-preview") return;
15
+
16
+ const index = Number(event.data.componentIndex);
17
+ if (!Number.isInteger(index)) return;
18
+
19
+ const anchor = document.getElementById(`journeybook-preview-component-${index}`);
20
+ if (!anchor) return;
21
+
22
+ window.scrollTo({
23
+ top: Math.max(0, anchor.getBoundingClientRect().top + window.scrollY - 48),
24
+ behavior: "smooth"
25
+ });
26
+ });
27
+ <% end %>
@@ -0,0 +1,23 @@
1
+ <% content_for :title, "Edit source: #{@page.title}" %>
2
+
3
+ <header class="jb-header">
4
+ <div>
5
+ <p class="jb-eyebrow">Page source</p>
6
+ <h1 class="jb-title"><%= @page.title %></h1>
7
+ <p class="jb-subtitle">Edit the complete Markdown source for this page.</p>
8
+ </div>
9
+ <%= link_to "Back to editor", edit_admin_page_path(@page, site_id: @page.site_id, tab: "layout"), class: "jb-button jb-button-secondary" %>
10
+ </header>
11
+
12
+ <%= form_with model: [:admin, @page], url: source_admin_page_path(@page, site_id: @page.site_id), method: :patch, class: "jb-form jb-source-editor" do |form| %>
13
+ <%= render "journeybook/admin/shared/errors", record: @page %>
14
+
15
+ <div class="jb-field jb-markdown">
16
+ <%= form.label :markdown, "Markdown source" %>
17
+ <%= form.text_area :markdown, required: true, spellcheck: false, autofocus: true %>
18
+ </div>
19
+
20
+ <div class="jb-actions">
21
+ <%= form.submit "Save source", class: "jb-button" %>
22
+ </div>
23
+ <% end %>
@@ -0,0 +1,9 @@
1
+ <%= form_with model: [:admin, redirect], class: "jb-form jb-card" do |form| %>
2
+ <%= render "journeybook/admin/shared/errors", record: redirect %>
3
+ <div class="jb-fields">
4
+ <div class="jb-field"><%= form.label :from %><%= form.text_field :from, required: true, placeholder: "/old-page" %></div>
5
+ <div class="jb-field"><%= form.label :to %><%= form.text_field :to, required: true, placeholder: "/new-page" %></div>
6
+ <div class="jb-field"><%= form.label :status_code %><%= form.select :status_code, Journeybook::Redirect::VALID_STATUS_CODES %></div>
7
+ </div>
8
+ <div class="jb-actions"><%= form.submit class: "jb-button" %><%= link_to "Back", admin_redirects_path, class: "jb-button jb-button-secondary" %></div>
9
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <header class="jb-header"><div><p class="jb-eyebrow">Redirects</p><h1 class="jb-title">Edit redirect</h1></div><%= button_to "Delete redirect", admin_redirect_path(@redirect), method: :delete, class: "jb-button jb-button-danger", data: { turbo_confirm: "Delete this redirect?" } %></header>
2
+ <%= render "form", redirect: @redirect %>
@@ -0,0 +1,15 @@
1
+ <header class="jb-header">
2
+ <div><p class="jb-eyebrow">Redirects</p><h1 class="jb-title">URL redirects</h1></div>
3
+ <%= link_to "New redirect", new_admin_redirect_path, class: "jb-button" %>
4
+ </header>
5
+
6
+ <% if @redirects.any? %>
7
+ <section class="jb-card jb-card-flush">
8
+ <table class="jb-table">
9
+ <thead><tr><th>From</th><th>To</th><th>Status</th><th></th></tr></thead>
10
+ <tbody><% @redirects.each do |redirect| %><tr><td><code><%= redirect.from %></code></td><td><code><%= redirect.to %></code></td><td><%= redirect.status_code %></td><td><%= link_to "Edit", edit_admin_redirect_path(redirect), class: "jb-button jb-button-secondary" %></td></tr><% end %></tbody>
11
+ </table>
12
+ </section>
13
+ <% else %>
14
+ <p class="jb-subtitle">No redirects have been configured.</p>
15
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <header class="jb-header"><div><p class="jb-eyebrow">Redirects</p><h1 class="jb-title">New redirect</h1></div></header>
2
+ <%= render "form", redirect: @redirect %>
@@ -0,0 +1,3 @@
1
+ <span class="jb-drag-handle" data-sortable-handle aria-label="Drag to reorder" role="img">
2
+ <svg viewBox="0 0 12 18" aria-hidden="true"><circle cx="3" cy="3" r="1.25"/><circle cx="9" cy="3" r="1.25"/><circle cx="3" cy="9" r="1.25"/><circle cx="9" cy="9" r="1.25"/><circle cx="3" cy="15" r="1.25"/><circle cx="9" cy="15" r="1.25"/></svg>
3
+ </span>
@@ -0,0 +1,10 @@
1
+ <% if record.errors.any? %>
2
+ <div class="jb-errors">
3
+ <strong><%= pluralize(record.errors.count, "error") %> prevented saving:</strong>
4
+ <ul>
5
+ <% record.errors.full_messages.each do |message| %>
6
+ <li><%= message %></li>
7
+ <% end %>
8
+ </ul>
9
+ </div>
10
+ <% end %>
@@ -0,0 +1,12 @@
1
+ <%= form_with model: [:admin, site], class: "jb-form jb-card" do |form| %>
2
+ <%= render "journeybook/admin/shared/errors", record: site %>
3
+ <div class="jb-fields">
4
+ <div class="jb-field"><%= form.label :name %><%= form.text_field :name, required: true %></div>
5
+ <div class="jb-field"><%= form.label :key %><%= form.text_field :key, required: true, placeholder: "marketing" %></div>
6
+ <div class="jb-field"><%= form.label :host %><%= form.text_field :host, required: true %></div>
7
+ <div class="jb-field"><%= form.label :path_prefix %><%= form.text_field :path_prefix, required: true, placeholder: "/blog" %></div>
8
+ <div class="jb-field"><%= form.label :layout %><%= form.text_field :layout, placeholder: "application" %></div>
9
+ <div class="jb-field"><%= form.label :default_locale %><%= form.text_field :default_locale, required: true %></div>
10
+ </div>
11
+ <div class="jb-actions"><%= form.submit "Save site", class: "jb-button" %><%= link_to "Back", admin_sites_path, class: "jb-button jb-button-secondary" %></div>
12
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <header class="jb-header"><div><p class="jb-eyebrow">Edit site</p><h1 class="jb-title"><%= @site.name %></h1></div></header>
2
+ <%= render "form", site: @site %>
@@ -0,0 +1,26 @@
1
+ <header class="jb-header">
2
+ <div>
3
+ <p class="jb-eyebrow">Sites</p>
4
+ <h1 class="jb-title">Sites</h1>
5
+ </div>
6
+ <%= link_to "New site", new_admin_site_path, class: "jb-button" %>
7
+ </header>
8
+
9
+ <section class="jb-card jb-card-flush">
10
+ <table class="jb-table">
11
+ <thead><tr><th>Name</th><th>Key</th><th>Host</th><th>Prefix</th><th>Layout</th><th>Locale</th><th></th></tr></thead>
12
+ <tbody>
13
+ <% @sites.each do |site| %>
14
+ <tr>
15
+ <td><strong><%= site.name %></strong></td>
16
+ <td><code><%= site.key %></code></td>
17
+ <td><code><%= site.host %></code></td>
18
+ <td><code><%= site.path_prefix %></code></td>
19
+ <td><code><%= site.layout_name %></code></td>
20
+ <td><%= site.default_locale %></td>
21
+ <td><div class="jb-actions"><%= link_to "Edit", edit_admin_site_path(site), class: "jb-button jb-button-secondary" %></div></td>
22
+ </tr>
23
+ <% end %>
24
+ </tbody>
25
+ </table>
26
+ </section>
@@ -0,0 +1,2 @@
1
+ <header class="jb-header"><div><p class="jb-eyebrow">New site</p><h1 class="jb-title">Create site</h1></div></header>
2
+ <%= render "form", site: @site %>
@@ -0,0 +1,57 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <title>Journeybook</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+
9
+ <%= yield :head %>
10
+
11
+ <%= stylesheet_link_tag "journeybook/application", media: "all" %>
12
+ <%= javascript_importmap_tags "journeybook" %>
13
+ </head>
14
+ <body>
15
+ <div class="jb-shell">
16
+ <aside class="jb-sidebar">
17
+ <a class="jb-brand" href="<%= root_path %>">
18
+ <%= image_tag "journeybook/logo.svg", class: "jb-brand-logo", alt: "" %>
19
+ <strong>JOURNEYBOOK</strong>
20
+ </a>
21
+ <nav class="jb-nav" aria-label="Admin navigation">
22
+ <%= link_to "Dashboard", root_path, class: ("is-active" if admin_nav_active?(:dashboard)), "aria-current": ("page" if admin_nav_active?(:dashboard)) %>
23
+ <%= link_to "Pages", admin_pages_path, class: ("is-active" if admin_nav_active?(:pages)), "aria-current": ("page" if admin_nav_active?(:pages)) %>
24
+ <%= link_to "Navigation", admin_navigation_menus_path, class: ("is-active" if admin_nav_active?(:navigation)), "aria-current": ("page" if admin_nav_active?(:navigation)) %>
25
+ <%= link_to "Sites", admin_sites_path, class: ("is-active" if admin_nav_active?(:sites)), "aria-current": ("page" if admin_nav_active?(:sites)) %>
26
+ <%= link_to "Redirects", admin_redirects_path, class: ("is-active" if admin_nav_active?(:redirects)), "aria-current": ("page" if admin_nav_active?(:redirects)) %>
27
+ <%= link_to "Components", admin_components_path, class: ("is-active" if admin_nav_active?(:components)), "aria-current": ("page" if admin_nav_active?(:components)) %>
28
+ <%= link_to "Library", admin_media_assets_path, class: ("is-active" if admin_nav_active?(:library)), "aria-current": ("page" if admin_nav_active?(:library)) %>
29
+ <%= link_to "AI Builder", new_admin_ai_page_path, class: ("is-active" if admin_nav_active?(:ai)), "aria-current": ("page" if admin_nav_active?(:ai)) %>
30
+ </nav>
31
+ <% if Journeybook::Site.exists? %>
32
+ <div class="jb-context">
33
+ <label for="journeybook_site_context">Site context</label>
34
+ <select id="journeybook_site_context" onchange="if (this.value) window.location = this.value">
35
+ <% Journeybook::Site.order(:name).each do |site| %>
36
+ <option value="<%= site_context_path(site) %>" <%= "selected" if site == current_site %>><%= site.name %></option>
37
+ <% end %>
38
+ </select>
39
+ </div>
40
+ <% end %>
41
+ </aside>
42
+
43
+ <main class="jb-main <%= content_for(:main_class) %>">
44
+ <%= yield %>
45
+ </main>
46
+ </div>
47
+ <div class="jb-flashes" aria-live="polite" aria-atomic="true">
48
+ <% if notice.present? %>
49
+ <p class="jb-notice" data-flash><%= notice %></p>
50
+ <% end %>
51
+ <% if alert.present? %>
52
+ <p class="jb-alert" data-flash><%= alert %></p>
53
+ <% end %>
54
+ </div>
55
+ <%= javascript_include_tag "journeybook/sortable", defer: true %>
56
+ </body>
57
+ </html>
@@ -0,0 +1,3 @@
1
+ pin "journeybook", to: "journeybook/application.js"
2
+ pin "journeybook/component_search_controller", to: "journeybook/component_search_controller.js"
3
+ pin "@hotwired/stimulus", to: "stimulus.min.js"
data/config/routes.rb ADDED
@@ -0,0 +1,39 @@
1
+ Journeybook::Engine.routes.draw do
2
+ root "admin/dashboard#show"
3
+ post "form", to: "form_submissions#create", as: :form_submission
4
+
5
+ namespace :admin, path: "/" do
6
+ resource :dashboard, only: :show, controller: "dashboard"
7
+ resources :sites, except: :show
8
+ resources :redirects, except: :show
9
+ resources :pages, except: :show do
10
+ member do
11
+ get :source
12
+ patch :source, action: :update_source
13
+ get :preview
14
+ get :errors
15
+ get "component/new", action: :new_component, as: :new_component
16
+ post "component", action: :add_component, as: :add_component
17
+ patch :component
18
+ patch "component/move", action: :move_component, as: :move_component
19
+ patch "component/visibility", action: :toggle_component_visibility, as: :toggle_component_visibility
20
+ patch "components/reorder", action: :reorder_components, as: :reorder_components
21
+ delete "component", action: :delete_component, as: :delete_component
22
+ patch :publish
23
+ patch :draft
24
+ post :duplicate
25
+ end
26
+ end
27
+ resources :navigation_menus, path: "navigation", except: :show do
28
+ resources :navigation_items, only: %i[new create edit update destroy] do
29
+ patch :move, on: :member
30
+ patch :reorder, on: :collection
31
+ end
32
+ end
33
+ resources :components, only: :index
34
+ resources :media_assets, path: "library", except: %i[show edit update]
35
+ get "components/:component/preview", to: "components#preview", as: :component_preview
36
+ resources :ai_style_prompts, path: "ai/styles"
37
+ resource :ai_page, only: %i[new create], path: "ai"
38
+ end
39
+ end
@@ -0,0 +1,13 @@
1
+ class CreateJourneybookSites < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :journeybook_sites do |t|
4
+ t.string :name, null: false
5
+ t.string :host, null: false
6
+ t.string :default_locale, null: false, default: "en"
7
+
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :journeybook_sites, :host, unique: true
12
+ end
13
+ end
@@ -0,0 +1,23 @@
1
+ class CreateJourneybookPages < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :journeybook_pages do |t|
4
+ t.references :site, null: false, foreign_key: { to_table: :journeybook_sites }
5
+ t.string :title, null: false
6
+ t.string :slug, null: false
7
+ t.string :path, null: false
8
+ t.text :markdown, null: false
9
+ t.string :meta_title
10
+ t.text :meta_description
11
+ t.string :status, null: false, default: "draft"
12
+ t.datetime :published_at
13
+ t.string :layout
14
+ t.string :locale, null: false, default: "en"
15
+
16
+ t.timestamps
17
+ end
18
+
19
+ add_index :journeybook_pages, %i[site_id locale slug], unique: true
20
+ add_index :journeybook_pages, %i[site_id locale path], unique: true
21
+ add_index :journeybook_pages, :status
22
+ end
23
+ end
@@ -0,0 +1,13 @@
1
+ class CreateJourneybookRedirects < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :journeybook_redirects do |t|
4
+ t.string :from, null: false
5
+ t.string :to, null: false
6
+ t.integer :status_code, null: false, default: 301
7
+
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :journeybook_redirects, :from, unique: true
12
+ end
13
+ end
@@ -0,0 +1,38 @@
1
+ class AddContextFieldsToJourneybookSites < ActiveRecord::Migration[8.0]
2
+ def change
3
+ remove_index :journeybook_sites, :host if index_exists?(:journeybook_sites, :host, unique: true)
4
+
5
+ add_column :journeybook_sites, :key, :string
6
+ add_column :journeybook_sites, :path_prefix, :string, null: false, default: "/"
7
+ add_column :journeybook_sites, :layout, :string
8
+
9
+ reversible do |dir|
10
+ dir.up do
11
+ say_with_time "Backfilling Journeybook site keys" do
12
+ site_class = Class.new(ActiveRecord::Base) do
13
+ self.table_name = "journeybook_sites"
14
+ end
15
+
16
+ used_keys = {}
17
+ site_class.order(:id).find_each do |site|
18
+ base_key = site.name.to_s.parameterize(separator: "_").presence || "site"
19
+ key = base_key
20
+ suffix = 2
21
+
22
+ while used_keys.key?(key)
23
+ key = "#{base_key}_#{suffix}"
24
+ suffix += 1
25
+ end
26
+
27
+ used_keys[key] = true
28
+ site.update_columns(key: key)
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ add_index :journeybook_sites, :key, unique: true
35
+ add_index :journeybook_sites, :host
36
+ add_index :journeybook_sites, %i[host path_prefix], unique: true
37
+ end
38
+ end
@@ -0,0 +1,24 @@
1
+ class CreateJourneybookMediaAssets < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :journeybook_media_assets do |t|
4
+ t.string :title, null: false
5
+ t.string :alt_text
6
+ t.text :description
7
+ t.string :tags
8
+ t.string :storage_key, null: false
9
+ t.string :original_filename, null: false
10
+ t.string :content_type, null: false
11
+ t.integer :byte_size, null: false
12
+ t.string :checksum
13
+ t.integer :width
14
+ t.integer :height
15
+ t.json :metadata
16
+
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :journeybook_media_assets, :storage_key, unique: true
21
+ add_index :journeybook_media_assets, :title
22
+ add_index :journeybook_media_assets, :content_type
23
+ end
24
+ end
@@ -0,0 +1,28 @@
1
+ class CreateJourneybookNavigation < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :journeybook_navigation_menus do |t|
4
+ t.references :site, null: false, foreign_key: { to_table: :journeybook_sites }
5
+ t.string :key, null: false
6
+ t.string :title, null: false
7
+ t.integer :position, null: false, default: 0
8
+
9
+ t.timestamps
10
+ end
11
+
12
+ add_index :journeybook_navigation_menus, %i[site_id key], unique: true
13
+
14
+ create_table :journeybook_navigation_items do |t|
15
+ t.references :navigation_menu, null: false, foreign_key: { to_table: :journeybook_navigation_menus }
16
+ t.references :page, foreign_key: { to_table: :journeybook_pages }
17
+ t.string :label, null: false
18
+ t.text :description
19
+ t.string :external_url
20
+ t.integer :position, null: false, default: 0
21
+ t.boolean :visible, null: false, default: true
22
+
23
+ t.timestamps
24
+ end
25
+
26
+ add_index :journeybook_navigation_items, %i[navigation_menu_id position]
27
+ end
28
+ end
@@ -0,0 +1,5 @@
1
+ class AddOpenInNewWindowToJourneybookNavigationItems < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :journeybook_navigation_items, :open_in_new_window, :boolean, default: false, null: false
4
+ end
5
+ end
@@ -0,0 +1,12 @@
1
+ class CreateJourneybookAiStylePrompts < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :journeybook_ai_style_prompts do |t|
4
+ t.string :name, null: false
5
+ t.text :prompt, null: false
6
+
7
+ t.timestamps
8
+ end
9
+
10
+ add_index :journeybook_ai_style_prompts, :name, unique: true
11
+ end
12
+ end
@@ -0,0 +1,9 @@
1
+ class AddBlogFieldsToJourneybookPages < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :journeybook_pages, :page_type, :string, null: false, default: "page"
4
+ add_column :journeybook_pages, :excerpt, :text
5
+ add_column :journeybook_pages, :author_name, :string
6
+
7
+ add_index :journeybook_pages, :page_type
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ class AddKnowledgeBaseFieldsToJourneybookPages < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_reference :journeybook_pages, :parent, foreign_key: { to_table: :journeybook_pages }
4
+ add_column :journeybook_pages, :topic, :string
5
+
6
+ add_index :journeybook_pages, %i[site_id page_type topic]
7
+ end
8
+ end
@@ -0,0 +1,6 @@
1
+ class AddPositionToJourneybookPages < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :journeybook_pages, :position, :integer, null: false, default: 0
4
+ add_index :journeybook_pages, %i[site_id page_type parent_id position]
5
+ end
6
+ end
@@ -0,0 +1,72 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record/migration"
3
+
4
+ module Journeybook
5
+ module Generators
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include ActiveRecord::Generators::Migration
8
+
9
+ MIGRATION_TEMPLATES = %w[
10
+ create_journeybook_sites.rb
11
+ create_journeybook_pages.rb
12
+ create_journeybook_redirects.rb
13
+ add_context_fields_to_journeybook_sites.rb
14
+ create_journeybook_media_assets.rb
15
+ create_journeybook_navigation.rb
16
+ add_open_in_new_window_to_journeybook_navigation_items.rb
17
+ create_journeybook_ai_style_prompts.rb
18
+ add_blog_fields_to_journeybook_pages.rb
19
+ add_knowledge_base_fields_to_journeybook_pages.rb
20
+ add_position_to_journeybook_pages.rb
21
+ ].freeze
22
+
23
+ source_root File.expand_path("templates", __dir__)
24
+
25
+ def self.next_migration_number(dirname)
26
+ @migration_number ||= Time.current.utc.strftime("%Y%m%d%H%M%S").to_i
27
+ (@migration_number += 1).to_s
28
+ end
29
+
30
+ def copy_migrations
31
+ MIGRATION_TEMPLATES.each do |migration|
32
+ migration_template migration, "db/migrate/#{migration}"
33
+ end
34
+ end
35
+
36
+ def copy_initializer
37
+ template "journeybook.rb", "config/initializers/journeybook.rb"
38
+ end
39
+
40
+ def copy_public_page_files
41
+ template "pages_controller.rb", "app/controllers/pages_controller.rb"
42
+ copy_file "show.html.erb", "app/views/pages/show.html.erb"
43
+ end
44
+
45
+ def ensure_routes_file
46
+ create_file "config/routes.rb", "Rails.application.routes.draw do\nend\n" unless File.exist?(File.join(destination_root, "config/routes.rb"))
47
+ end
48
+
49
+ def mount_admin_routes
50
+ route 'mount Journeybook::Engine => "/journeybook"'
51
+ end
52
+
53
+ def add_public_routes
54
+ route 'root "pages#show"'
55
+ route <<~RUBY
56
+ constraints Journeybook::PublicPageConstraint.new do
57
+ get "*path" => "pages#show", as: :journeybook_page
58
+ end
59
+ RUBY
60
+ end
61
+
62
+ def create_component_directories
63
+ empty_directory "app/components"
64
+ empty_directory "app/components/previews"
65
+ empty_directory "test/components/previews"
66
+ create_file "app/components/.keep"
67
+ create_file "app/components/previews/.keep"
68
+ create_file "test/components/previews/.keep"
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,9 @@
1
+ class AddBlogFieldsToJourneybookPages < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :journeybook_pages, :page_type, :string, null: false, default: "page"
4
+ add_column :journeybook_pages, :excerpt, :text
5
+ add_column :journeybook_pages, :author_name, :string
6
+
7
+ add_index :journeybook_pages, :page_type
8
+ end
9
+ end
@@ -0,0 +1,38 @@
1
+ class AddContextFieldsToJourneybookSites < ActiveRecord::Migration[8.0]
2
+ def change
3
+ remove_index :journeybook_sites, :host if index_exists?(:journeybook_sites, :host, unique: true)
4
+
5
+ add_column :journeybook_sites, :key, :string
6
+ add_column :journeybook_sites, :path_prefix, :string, null: false, default: "/"
7
+ add_column :journeybook_sites, :layout, :string
8
+
9
+ reversible do |dir|
10
+ dir.up do
11
+ say_with_time "Backfilling Journeybook site keys" do
12
+ site_class = Class.new(ActiveRecord::Base) do
13
+ self.table_name = "journeybook_sites"
14
+ end
15
+
16
+ used_keys = {}
17
+ site_class.order(:id).find_each do |site|
18
+ base_key = site.name.to_s.parameterize(separator: "_").presence || "site"
19
+ key = base_key
20
+ suffix = 2
21
+
22
+ while used_keys.key?(key)
23
+ key = "#{base_key}_#{suffix}"
24
+ suffix += 1
25
+ end
26
+
27
+ used_keys[key] = true
28
+ site.update_columns(key: key)
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ add_index :journeybook_sites, :key, unique: true
35
+ add_index :journeybook_sites, :host
36
+ add_index :journeybook_sites, %i[host path_prefix], unique: true
37
+ end
38
+ end