diff --git a/Gemfile b/Gemfile index 66ee9aa..75ccbd0 100644 --- a/Gemfile +++ b/Gemfile @@ -16,6 +16,7 @@ gem 'propshaft' gem 'recaptcha' gem 'puma', '>= 5.0' gem 'rails', '~> 8.1.2' +gem 'redcarpet' gem 'solid_cache' gem 'solid_queue' gem 'stimulus-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 9f79d8c..0776e22 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -333,6 +333,7 @@ GEM psych (>= 4.0.0) tsort recaptcha (5.21.1) + redcarpet (3.6.1) regexp_parser (2.11.3) reline (0.6.3) io-console (~> 0.5) @@ -503,6 +504,7 @@ DEPENDENCIES puma (>= 5.0) rails (~> 8.1.2) recaptcha + redcarpet rspec-rails rubocop-capybara rubocop-factory_bot diff --git a/app/controllers/markdown_previews_controller.rb b/app/controllers/markdown_previews_controller.rb new file mode 100644 index 0000000..19616ef --- /dev/null +++ b/app/controllers/markdown_previews_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class MarkdownPreviewsController < ApplicationController + include MarkdownHelper + + before_action :authenticate_user! + + # POST /markdown_previews + def create + html = render_markdown(params[:text]) + render json: { html: html } + end +end diff --git a/app/helpers/markdown_helper.rb b/app/helpers/markdown_helper.rb new file mode 100644 index 0000000..ad3f7d8 --- /dev/null +++ b/app/helpers/markdown_helper.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module MarkdownHelper + include ActionView::Helpers::SanitizeHelper + include ActionView::Helpers::UrlHelper + # Markdownテキストをレンダリング + # カスタムボタンタグ: テキスト + def render_markdown(text) + return '' if text.blank? + + # 1. カスタムボタンタグを一時的にプレースホルダーに置き換え + text_with_placeholders, buttons = extract_button_links(text) + + # 2. Markdownをパース + html = markdown_to_html(text_with_placeholders) + + # 3. プレースホルダーをボタンHTMLに戻す + html = restore_button_placeholders(html, buttons) + + # 4. サニタイズ(許可リスト方式) + sanitize_markdown_html(html) + end + + private + + # カスタムボタンタグを抽出してプレースホルダーに置き換え + def extract_button_links(text) + buttons = [] + text_with_placeholders = text.gsub(/([^<]+)<\/button-link>/m) do + url = Regexp.last_match(1) + label = Regexp.last_match(2) + + # URLの基本的なバリデーション + next '' unless url.match?(%r{\Ahttps?://}) + + # 一意なプレースホルダーを生成(ユーザー入力との衝突を防ぐ) + placeholder = "BUTTONPLACEHOLDER-#{SecureRandom.hex(8)}" + buttons << { url: url, label: label, placeholder: placeholder } + placeholder + end + + [ text_with_placeholders, buttons ] + end + + # MarkdownをHTMLに変換 + def markdown_to_html(text) + renderer = Redcarpet::Render::HTML.new( + escape_html: false, + hard_wrap: true, + link_attributes: { rel: 'nofollow noopener noreferrer', target: '_blank' } + ) + + markdown = Redcarpet::Markdown.new( + renderer, + autolink: true, + tables: true, + fenced_code_blocks: true, + strikethrough: true, + no_intra_emphasis: true, + space_after_headers: true + ) + + markdown.render(text) + end + + # プレースホルダーをボタンHTMLに戻す + def restore_button_placeholders(html, buttons) + return html if buttons.empty? + + buttons.each do |button| + button_html = link_to( + button[:label], + button[:url], + target: '_blank', + rel: 'noopener noreferrer', + class: 'inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-500 transition-colors no-underline' + ) + html = html.gsub(button[:placeholder], button_html) + end + + html + end + + # HTMLをサニタイズ + def sanitize_markdown_html(html) + Rails::HTML5::SafeListSanitizer.new.sanitize( + html, + tags: %w[ + p br strong em del code pre + h1 h2 h3 h4 h5 h6 + ul ol li + a + table thead tbody tr th td + blockquote + ], + attributes: %w[href target rel class align] + ) + end +end diff --git a/app/javascript/controllers/markdown_editor_controller.js b/app/javascript/controllers/markdown_editor_controller.js new file mode 100644 index 0000000..e23f01f --- /dev/null +++ b/app/javascript/controllers/markdown_editor_controller.js @@ -0,0 +1,67 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['input', 'preview', 'editTab', 'previewTab', 'editPane', 'previewPane']; + static values = { + previewUrl: String, + }; + + connect() { + this.showEdit(); + } + + showEdit() { + this.editPaneTarget.classList.remove('hidden'); + this.previewPaneTarget.classList.add('hidden'); + + this.editTabTarget.classList.add('border-b-2', 'border-blue-600', 'text-blue-600'); + this.editTabTarget.classList.remove('text-gray-600'); + + this.previewTabTarget.classList.remove('border-b-2', 'border-blue-600', 'text-blue-600'); + this.previewTabTarget.classList.add('text-gray-600'); + } + + async showPreview() { + this.editPaneTarget.classList.add('hidden'); + this.previewPaneTarget.classList.remove('hidden'); + + this.previewTabTarget.classList.add('border-b-2', 'border-blue-600', 'text-blue-600'); + this.previewTabTarget.classList.remove('text-gray-600'); + + this.editTabTarget.classList.remove('border-b-2', 'border-blue-600', 'text-blue-600'); + this.editTabTarget.classList.add('text-gray-600'); + + // プレビューを更新 + await this.updatePreview(); + } + + async updatePreview() { + const text = this.inputTarget.value; + + if (!text.trim()) { + this.previewTarget.innerHTML = '

プレビューするテキストがありません

'; + return; + } + + try { + const response = await fetch(this.previewUrlValue, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content, + }, + body: JSON.stringify({ text }), + }); + + if (!response.ok) { + throw new Error('Preview failed'); + } + + const data = await response.json(); + this.previewTarget.innerHTML = data.html; + } catch (error) { + console.error('Preview error:', error); + this.previewTarget.innerHTML = '

プレビューの生成に失敗しました

'; + } + } +} diff --git a/app/views/home/help.html.erb b/app/views/home/help.html.erb index 5b9f9aa..b7b48f4 100644 --- a/app/views/home/help.html.erb +++ b/app/views/home/help.html.erb @@ -1,14 +1,132 @@ <% content_for :title, full_title("ヘルプ") %> -
-

ヘルプ

- -
-
-

よくある質問

-

...

-
- - +
+

ヘルプ

+ +
+
+

マークダウン記法ガイド

+ +

+ イベント説明文と開催レポートでは、マークダウン記法を使って文章を装飾できます。 +

+ +
+ +
+

見出し

+
+ + # 大見出し
+ ## 中見出し
+ ### 小見出し +
+
+
+ + +
+

太字・斜体

+
+ + **太字にする**
+ *斜体にする*
+ ~~取り消し線~~ +
+
+
+ + +
+

リンク

+
+ + [リンクテキスト](https://example.com) + +
+

通常のテキストリンクになります。

+
+ + +
+

ボタンリンク(カスタム記法)

+
+ + <button-link url="https://slack.com/channels/xxx">Slackに参加</button-link> + +
+

青いボタンとして表示されます。チャットへのリンクなどに便利です。

+
+

プレビュー:

+ + Slackに参加 + +
+
+ + +
+

リスト

+
+ + - 項目1
+ - 項目2
+ - 項目3
+
+ 1. 番号付き項目1
+ 2. 番号付き項目2
+ 3. 番号付き項目3 +
+
+
+ + +
+

引用

+
+ + > これは引用文です + +
+
+ + +
+

コードブロック

+
+ + ```
+ コードをここに書く
+ ``` +
+
+
+ + +
+

テーブル

+
+ + | 列1 | 列2 | 列3 |
+ | --- | --- | --- |
+ | 値1 | 値2 | 値3 |
+ | 値4 | 値5 | 値6 | +
+
+
+
+ +
+

プレビュー機能

+

+ イベント作成・編集画面では「プレビュー」タブで実際の見た目を確認できます。 +

+
+
+ +
+

よくある質問

+

準備中...

+
diff --git a/app/views/shared/_markdown_editor.html.erb b/app/views/shared/_markdown_editor.html.erb new file mode 100644 index 0000000..a8419c1 --- /dev/null +++ b/app/views/shared/_markdown_editor.html.erb @@ -0,0 +1,68 @@ +<% + # パラメータ + form ||= nil + field_name ||= nil + label_text ||= nil + rows ||= 5 + required ||= false + help_text ||= nil + errors ||= [] +%> + +
+ +
+
+ <%= form.label field_name, label_text %> + <% if required %> + 必須 + <% end %> +
+ + + マークダウン記法ガイド + +
+ + +
+ + +
+ + +
+ <%= form.text_area field_name, + rows: rows, + data: { markdown_editor_target: "input" }, + class: ["block rounded-md border px-3 py-2 w-full font-mono text-sm", { + "border-gray-300 focus:outline-blue-600": errors.none?, + "border-red-400 focus:outline-red-600": errors.any? + }] %> + <% if help_text.present? %> +

<%= help_text %>

+ <% end %> +
+ + + +
diff --git a/app/views/teams/events/_form.html.erb b/app/views/teams/events/_form.html.erb index 7a4a227..a47ab4d 100644 --- a/app/views/teams/events/_form.html.erb +++ b/app/views/teams/events/_form.html.erb @@ -157,10 +157,12 @@
-
- <%= form.label :description, 'イベント説明' %> - <%= form.text_area :description, rows: 5, class: ["block rounded-md border px-3 py-2 mt-2 w-full", {"border-gray-300 focus:outline-blue-600": @event.errors[:description].none?, "border-red-400 focus:outline-red-600": @event.errors[:description].any?}] %> -
+ <%= render 'shared/markdown_editor', + form: form, + field_name: :description, + label_text: 'イベント説明', + rows: 8, + errors: @event.errors[:description] %>
@@ -221,9 +223,11 @@ <% end %>
-
- <%= form.label :report_content, '開催レポート本文' %> - <%= form.text_area :report_content, rows: 8, class: ["block rounded-md border px-3 py-2 mt-2 w-full", {"border-gray-300 focus:outline-blue-600": @event.errors[:report_content].none?, "border-red-400 focus:outline-red-600": @event.errors[:report_content].any?}] %> -

5000文字以内

-
+ <%= render 'shared/markdown_editor', + form: form, + field_name: :report_content, + label_text: '開催レポート本文', + rows: 10, + help_text: '5000文字以内', + errors: @event.errors[:report_content] %> diff --git a/app/views/teams/events/_main_column.html.erb b/app/views/teams/events/_main_column.html.erb index 81467e3..61e1675 100644 --- a/app/views/teams/events/_main_column.html.erb +++ b/app/views/teams/events/_main_column.html.erb @@ -97,7 +97,7 @@
<% if event.description.present? %> -
<%= event.description %>
+
<%= render_markdown(event.description).html_safe %>
<% else %>
イベントの詳細説明はありません
<% end %> @@ -112,7 +112,7 @@ <% end %> <% if event.report_content.present? %> -
<%= event.report_content %>
+
<%= render_markdown(event.report_content).html_safe %>
<% end %>
diff --git a/config/routes.rb b/config/routes.rb index b44c4c0..ca12546 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -48,6 +48,8 @@ registrations: 'users/registrations' } + resource :markdown_preview, only: [ :create ] + namespace :developer do root 'dashboard#index' end diff --git a/spec/helpers/markdown_helper_spec.rb b/spec/helpers/markdown_helper_spec.rb new file mode 100644 index 0000000..702a4cf --- /dev/null +++ b/spec/helpers/markdown_helper_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe MarkdownHelper, type: :helper do + describe '#render_markdown' do + it 'converts basic markdown to HTML' do + markdown = "**bold** and *italic*" + html = helper.render_markdown(markdown) + expect(html).to include('bold') + expect(html).to include('italic') + end + + it 'converts headings' do + markdown = "# Heading 1\n## Heading 2" + html = helper.render_markdown(markdown) + expect(html).to include('

Heading 1

') + expect(html).to include('

Heading 2

') + end + + it 'converts links' do + markdown = "[Link text](https://example.com)" + html = helper.render_markdown(markdown) + expect(html).to include('href="https://example.com"') + expect(html).to include('Link text') + expect(html).to include('target="_blank"') + expect(html).to include('rel="nofollow noopener noreferrer"') + end + + it 'converts lists' do + markdown = "- Item 1\n- Item 2\n- Item 3" + html = helper.render_markdown(markdown) + expect(html).to include('