-
Notifications
You must be signed in to change notification settings - Fork 0
イベント説明文と開催レポートにマークダウン対応を追加 #303
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module MarkdownHelper | ||
| include ActionView::Helpers::SanitizeHelper | ||
| include ActionView::Helpers::UrlHelper | ||
| # Markdownテキストをレンダリング | ||
| # カスタムボタンタグ: <button-link url="...">テキスト</button-link> | ||
| 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\s+url="([^"]+)">([^<]+)<\/button-link>/m) do | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 現在の正規表現 より柔軟な非貪欲マッチ /<button-link\s+url="([^"]+)">(.*?)<\/button-link>/mこれにより、ラベル内のテキストが text_with_placeholders = text.gsub(/<button-link\s+url="([^"]+)">(.*?)<\/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)}" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = '<p class="text-gray-500">プレビューするテキストがありません</p>'; | ||
| 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 = '<p class="text-red-500">プレビューの生成に失敗しました</p>'; | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,132 @@ | ||
| <% content_for :title, full_title("ヘルプ") %> | ||
|
|
||
| <div class="container mx-auto px-4 py-8"> | ||
| <h1 class="text-3xl font-bold mb-6">ヘルプ</h1> | ||
|
|
||
| <div class="space-y-6"> | ||
| <div> | ||
| <h2 class="text-xl font-semibold">よくある質問</h2> | ||
| <p>...</p> | ||
| </div> | ||
|
|
||
| <!-- ヘルプの内容 --> | ||
| <div class="container mx-auto px-4 py-8 max-w-4xl"> | ||
| <h1 class="text-3xl font-bold mb-8">ヘルプ</h1> | ||
|
|
||
| <div class="space-y-8"> | ||
| <section id="markdown" class="bg-white rounded-lg shadow-sm p-6"> | ||
| <h2 class="text-2xl font-bold mb-6 pb-2 border-b">マークダウン記法ガイド</h2> | ||
|
|
||
| <p class="text-gray-700 mb-6"> | ||
| イベント説明文と開催レポートでは、マークダウン記法を使って文章を装飾できます。 | ||
| </p> | ||
|
|
||
| <div class="space-y-6"> | ||
| <!-- 見出し --> | ||
| <div> | ||
| <h3 class="text-lg font-semibold mb-3">見出し</h3> | ||
| <div class="bg-gray-50 p-4 rounded border mb-2"> | ||
| <code class="text-sm"> | ||
| # 大見出し<br> | ||
| ## 中見出し<br> | ||
| ### 小見出し | ||
| </code> | ||
| </div> | ||
| </div> | ||
|
|
||
| <!-- 太字・斜体 --> | ||
| <div> | ||
| <h3 class="text-lg font-semibold mb-3">太字・斜体</h3> | ||
| <div class="bg-gray-50 p-4 rounded border mb-2"> | ||
| <code class="text-sm"> | ||
| **太字にする**<br> | ||
| *斜体にする*<br> | ||
| ~~取り消し線~~ | ||
| </code> | ||
| </div> | ||
| </div> | ||
|
|
||
| <!-- リンク --> | ||
| <div> | ||
| <h3 class="text-lg font-semibold mb-3">リンク</h3> | ||
| <div class="bg-gray-50 p-4 rounded border mb-2"> | ||
| <code class="text-sm"> | ||
| [リンクテキスト](https://example.com) | ||
| </code> | ||
| </div> | ||
| <p class="text-sm text-gray-600">通常のテキストリンクになります。</p> | ||
| </div> | ||
|
|
||
| <!-- ボタンリンク --> | ||
| <div> | ||
| <h3 class="text-lg font-semibold mb-3">ボタンリンク(カスタム記法)</h3> | ||
| <div class="bg-gray-50 p-4 rounded border mb-2"> | ||
| <code class="text-sm"> | ||
| <button-link url="https://slack.com/channels/xxx">Slackに参加</button-link> | ||
| </code> | ||
| </div> | ||
| <p class="text-sm text-gray-600 mb-2">青いボタンとして表示されます。チャットへのリンクなどに便利です。</p> | ||
| <div class="p-4 bg-white border rounded"> | ||
| <p class="text-xs text-gray-500 mb-2">プレビュー:</p> | ||
| <a href="#" class="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-500 transition-colors no-underline"> | ||
| Slackに参加 | ||
| </a> | ||
| </div> | ||
| </div> | ||
|
|
||
| <!-- リスト --> | ||
| <div> | ||
| <h3 class="text-lg font-semibold mb-3">リスト</h3> | ||
| <div class="bg-gray-50 p-4 rounded border mb-2"> | ||
| <code class="text-sm"> | ||
| - 項目1<br> | ||
| - 項目2<br> | ||
| - 項目3<br> | ||
| <br> | ||
| 1. 番号付き項目1<br> | ||
| 2. 番号付き項目2<br> | ||
| 3. 番号付き項目3 | ||
| </code> | ||
| </div> | ||
| </div> | ||
|
|
||
| <!-- 引用 --> | ||
| <div> | ||
| <h3 class="text-lg font-semibold mb-3">引用</h3> | ||
| <div class="bg-gray-50 p-4 rounded border mb-2"> | ||
| <code class="text-sm"> | ||
| > これは引用文です | ||
| </code> | ||
| </div> | ||
| </div> | ||
|
|
||
| <!-- コードブロック --> | ||
| <div> | ||
| <h3 class="text-lg font-semibold mb-3">コードブロック</h3> | ||
| <div class="bg-gray-50 p-4 rounded border mb-2"> | ||
| <code class="text-sm"> | ||
| ```<br> | ||
| コードをここに書く<br> | ||
| ``` | ||
| </code> | ||
| </div> | ||
| </div> | ||
|
|
||
| <!-- テーブル --> | ||
| <div> | ||
| <h3 class="text-lg font-semibold mb-3">テーブル</h3> | ||
| <div class="bg-gray-50 p-4 rounded border mb-2"> | ||
| <code class="text-sm"> | ||
| | 列1 | 列2 | 列3 |<br> | ||
| | --- | --- | --- |<br> | ||
| | 値1 | 値2 | 値3 |<br> | ||
| | 値4 | 値5 | 値6 | | ||
| </code> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="mt-6 p-4 bg-blue-50 border border-blue-200 rounded"> | ||
| <h4 class="font-semibold text-blue-900 mb-2">プレビュー機能</h4> | ||
| <p class="text-sm text-blue-800"> | ||
| イベント作成・編集画面では「プレビュー」タブで実際の見た目を確認できます。 | ||
| </p> | ||
| </div> | ||
| </section> | ||
|
|
||
| <section class="bg-white rounded-lg shadow-sm p-6"> | ||
| <h2 class="text-2xl font-bold mb-4 pb-2 border-b">よくある質問</h2> | ||
| <p class="text-gray-600">準備中...</p> | ||
| </section> | ||
| </div> | ||
| </div> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
このメソッドは簡潔にできます。
render_markdownヘルパーは既に空のテキスト入力を処理して空文字列を返すため、コントローラーでの明示的なチェックは不要です。