From 3631b29dbc458984a92659de6c418561a718a0c3 Mon Sep 17 00:00:00 2001 From: Yijun Zhou Date: Thu, 23 Apr 2026 00:49:11 -0700 Subject: [PATCH] Add Conference Dynamic Variable Substitution in Markdown --- app/controllers/conferences_controller.rb | 8 +- app/helpers/application_helper.rb | 1 + app/helpers/conference_template_helper.rb | 8 ++ app/services/conference_markdown_template.rb | 112 ++++++++++++++++++ .../admin/conferences/_form_fields.html.haml | 56 +++++++++ .../conferences/_about_and_happening_now.haml | 4 +- app/views/conferences/_header.haml | 8 +- .../conferences_controller_spec.rb | 11 ++ .../conference_markdown_template_spec.rb | 61 ++++++++++ 9 files changed, 262 insertions(+), 7 deletions(-) create mode 100644 app/helpers/conference_template_helper.rb create mode 100644 app/services/conference_markdown_template.rb create mode 100644 spec/services/conference_markdown_template_spec.rb diff --git a/app/controllers/conferences_controller.rb b/app/controllers/conferences_controller.rb index 492516829..cb2e33011 100644 --- a/app/controllers/conferences_controller.rb +++ b/app/controllers/conferences_controller.rb @@ -28,7 +28,13 @@ def show @splashpage = @conference.splashpage - redirect_to admin_conference_splashpage_path(@conference.short_title) && return unless @splashpage.present? + # NOTE: do not write `redirect_to(...) && return unless ...` — Ruby parses that as + # `redirect_to((...) && return) unless ...`, which evaluates `return` before `redirect_to` + # when the splashpage is missing, skipping the redirect and rendering show with a nil splashpage. + unless @splashpage.present? + redirect_to admin_conference_splashpage_path(@conference.short_title) + return + end # User messages at the top of the page. @unpaid_tickets = current_user_has_unpaid_tickets? diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 492c98a7f..5c7f1877a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -5,6 +5,7 @@ # TODO-SNAPCON: Refactor this module. Move chunks to a dates_help, some events_helper module ApplicationHelper include Pagy::Frontend + include ConferenceTemplateHelper # Returns a string build from the start and end date of the given conference. # # If the conference is only one day long diff --git a/app/helpers/conference_template_helper.rb b/app/helpers/conference_template_helper.rb new file mode 100644 index 000000000..1d39c9b0b --- /dev/null +++ b/app/helpers/conference_template_helper.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module ConferenceTemplateHelper + def conference_template_markdown(text, conference, escape_html: false) + interpolated = ConferenceMarkdownTemplate.interpolate(text, conference) + markdown(interpolated, escape_html) + end +end diff --git a/app/services/conference_markdown_template.rb b/app/services/conference_markdown_template.rb new file mode 100644 index 000000000..81890b5a5 --- /dev/null +++ b/app/services/conference_markdown_template.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +# Interpolates `{token}` placeholders in conference-facing Markdown fields. +# +# This intentionally mirrors the `{key}` substitution style used by +# `EmailTemplateParser`, but only exposes conference-scoped values (no user). +class ConferenceMarkdownTemplate + KNOWN_TEMPLATE_KEYS = %w[ + conference conference_short start_date end_date timezone + cfp_start_date cfp_end_date + venue venue_address + registration_start_date registration_end_date + num_public_event_types + ].freeze + + def self.interpolate(text, conference) + return '' if text.blank? + return text if conference.blank? + + values = build_values(conference) + substituted = EmailTemplateParser.parse_template(text, values) + + # Aliases / friendlier names for issue descriptions + alias_map = { + 'conference_name' => 'conference', + 'short_name' => 'conference_short', + 'cfp_deadline' => 'cfp_end_date' + } + + alias_map.each do |from, to| + raw = values[to] + replacement = + if raw.is_a?(Date) + raw.strftime('%Y-%m-%d') + else + raw.presence || '' + end + + substituted = substituted.gsub("{#{from}}", replacement) + end + + # `EmailTemplateParser.parse_template` skips blank values, which leaves tokens + # like `{cfp_end_date}` in place when optional sections are empty. For + # conference-facing Markdown we prefer removing those placeholders entirely. + substituted = substitute_known_keys(substituted, values) + + substituted + end + + def self.substitute_known_keys(text, values) + return text if text.blank? + + KNOWN_TEMPLATE_KEYS.each do |key| + next unless text.include?("{#{key}}") + + raw = values[key] + replacement = + if raw.is_a?(Date) + raw.strftime('%Y-%m-%d') + else + raw.presence || '' + end + + text = text.gsub("{#{key}}", replacement) + end + + text + end + + def self.build_values(conference) + h = { + 'conference' => conference.title, + 'conference_short' => conference.short_title, + 'start_date' => conference.start_date, + 'end_date' => conference.end_date, + 'timezone' => conference.timezone + } + + events_cfp = conference.program&.cfps&.find_by(cfp_type: 'events') + if events_cfp + h['cfp_start_date'] = events_cfp.start_date + h['cfp_end_date'] = events_cfp.end_date + else + h['cfp_start_date'] = '' + h['cfp_end_date'] = '' + end + + if conference.venue + h['venue'] = conference.venue.name + h['venue_address'] = conference.venue.address + else + h['venue'] = '' + h['venue_address'] = '' + end + + if conference.registration_period + h['registration_start_date'] = conference.registration_period.start_date + h['registration_end_date'] = conference.registration_period.end_date + else + h['registration_start_date'] = '' + h['registration_end_date'] = '' + end + + if conference.program + h['num_public_event_types'] = conference.program.event_types.available_for_public.count.to_s + else + h['num_public_event_types'] = '0' + end + + h + end +end diff --git a/app/views/admin/conferences/_form_fields.html.haml b/app/views/admin/conferences/_form_fields.html.haml index c5439f1ac..53149ab17 100644 --- a/app/views/admin/conferences/_form_fields.html.haml +++ b/app/views/admin/conferences/_form_fields.html.haml @@ -23,10 +23,66 @@ = f.label :description = f.text_area :description, rows: 5, data: { provide: 'markdown' }, class: 'form-control' .help-block= markdown_hint('Splash page content') + %a.btn.btn-link.control_label.template_help_link{ 'data-name' => 'conference_description_template_help' } Show available template variables + .template-help{ id: 'conference_description_template_help' } + %p + You can embed placeholders like + %code {conference} + which will be replaced on the public splash page using the current conference data. + %ul + %li + %code {conference} + (alias: + %code {conference_name} + ) — conference title + %li + %code {conference_short} + (alias: + %code {short_name} + ) — conference short title + %li + %code {start_date} + / + %code {end_date} + — conference dates (formatted as YYYY-MM-DD) + %li + %code {cfp_start_date} + / + %code {cfp_end_date} + — CFP for events dates (if configured) + %li + %code {cfp_deadline} + — alias for + %code {cfp_end_date} + %li + %code {venue} + — venue name (if configured) + %li + %code {venue_address} + — venue address (if configured) + %li + %code {registration_start_date} + / + %code {registration_end_date} + — registration period dates (if configured) + %li + %code {num_public_event_types} + — count of event types that allow public submission + %li + %code {timezone} + — conference timezone string .form-group = f.label :registered_attendees_message, 'Message for Registered Attendees' = f.text_area :registered_attendees_message, rows: 5, data: { provide: 'markdown' }, class: 'form-control' .help-block= markdown_hint('Splash page content') + %a.btn.btn-link.control_label.template_help_link{ 'data-name' => 'conference_registered_attendees_template_help' } Show available template variables + .template-help{ id: 'conference_registered_attendees_template_help' } + %p + Same placeholders as the conference description (for example + %code {conference} + and + %code {start_date} + ). .form-group = f.color_field :color, size: 6, class: 'form-control' %span.help-block diff --git a/app/views/conferences/_about_and_happening_now.haml b/app/views/conferences/_about_and_happening_now.haml index 05141967d..8ea4ff874 100644 --- a/app/views/conferences/_about_and_happening_now.haml +++ b/app/views/conferences/_about_and_happening_now.haml @@ -9,8 +9,8 @@ = content_for :about do #about -if @user_registered && conference.registered_attendees_message.present? - = markdown(conference.registered_attendees_message, false) - = markdown(conference.description, false) + = conference_template_markdown(conference.registered_attendees_message, conference, escape_html: false) + = conference_template_markdown(conference.description, conference, escape_html: false) %section#about-and-happening-now .container diff --git a/app/views/conferences/_header.haml b/app/views/conferences/_header.haml index 72d634896..40a4ffa41 100644 --- a/app/views/conferences/_header.haml +++ b/app/views/conferences/_header.haml @@ -1,11 +1,11 @@ -- cache [conference, venue, '#splash#header'] do - #banner{ style: ("background-image: url(#{splashpage.banner_photo_url})" if splashpage.banner_photo_url) } +- cache [conference, venue, splashpage, '#splash#header'] do + #banner{ style: ("background-image: url(#{splashpage.banner_photo_url})" if splashpage&.banner_photo_url) } .container .row - - picture_present = splashpage.banner_photo? || conference.picture? + - picture_present = splashpage&.banner_photo? || conference.picture? .col-md-6.col-md-offset-3{ id: (picture_present ? "header-image" : "header-no-image") } .row - - if conference.picture? && !splashpage.banner_photo? + - if conference.picture? && !splashpage&.banner_photo? .col-md-4 = image_tag(conference.picture_url, class: 'img-responsive img-center', diff --git a/spec/controllers/conferences_controller_spec.rb b/spec/controllers/conferences_controller_spec.rb index f6c81de44..f781405c7 100644 --- a/spec/controllers/conferences_controller_spec.rb +++ b/spec/controllers/conferences_controller_spec.rb @@ -27,6 +27,17 @@ end end + context 'when the conference has no splashpage' do + let(:conference_without_splash) { create(:conference) } + let!(:organizer) { create(:organizer, resource: conference_without_splash) } + + it 'redirects organizers to admin splashpage setup (regression: redirect must not be skipped by Ruby parsing)' do + sign_in organizer + get :show, params: { id: conference_without_splash.short_title } + expect(response).to redirect_to(admin_conference_splashpage_path(conference_without_splash.short_title)) + end + end + context 'accessing conference via custom domain' do before do conference.update_attribute(:custom_domain, 'lvh.me') diff --git a/spec/services/conference_markdown_template_spec.rb b/spec/services/conference_markdown_template_spec.rb new file mode 100644 index 000000000..ca525452e --- /dev/null +++ b/spec/services/conference_markdown_template_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ConferenceMarkdownTemplate do + describe '.interpolate' do + it 'returns empty string for blank text' do + conference = build(:conference) + expect(described_class.interpolate('', conference)).to eq('') + expect(described_class.interpolate(nil, conference)).to eq('') + end + + it 'returns original text when conference is blank' do + expect(described_class.interpolate('{conference}', nil)).to eq('{conference}') + end + + it 'substitutes conference values, CFP (events), venue, registration, and public event type count' do + conference = create(:full_conference) + conference.program.event_types.first.update!(enable_public_submission: false) + create(:event_type, program: conference.program, title: 'Poster', enable_public_submission: true) + + events_cfp = conference.program.cfps.find_by!(cfp_type: 'events') + template = <<~TEXT.squish + {conference} / {conference_short} + {conference_name} / {short_name} + {start_date}–{end_date} {timezone} + CFP {cfp_start_date}–{cfp_end_date} deadline {cfp_deadline} + {venue} — {venue_address} + Reg {registration_start_date}–{registration_end_date} + Public types: {num_public_event_types} + TEXT + + result = described_class.interpolate(template, conference) + + expect(result).to include(conference.title) + expect(result).to include(conference.short_title) + expect(result).to include("#{conference.start_date.strftime('%Y-%m-%d')}–#{conference.end_date.strftime('%Y-%m-%d')}") + expect(result).to include(conference.timezone) + expect(result).to include("#{events_cfp.start_date.strftime('%Y-%m-%d')}–#{events_cfp.end_date.strftime('%Y-%m-%d')}") + expect(result).to include(conference.venue.name) + expect(result).to include(conference.venue.address) + expect(result).to include( + "#{conference.registration_period.start_date.strftime('%Y-%m-%d')}–#{conference.registration_period.end_date.strftime('%Y-%m-%d')}" + ) + expect(result).to include('Public types: 2') + end + + it 'uses empty strings for optional sections when not configured (but still counts default public event types)' do + conference = create(:conference) + conference.program.cfps.destroy_all + conference.venue&.destroy + conference.registration_period&.destroy + conference.reload + + template = 'CFP:{cfp_end_date}|Venue:{venue}|Reg:{registration_end_date}|Types:{num_public_event_types}' + result = described_class.interpolate(template, conference) + + expect(result).to eq('CFP:|Venue:|Reg:|Types:2') + end + end +end