Merge pull request #23337 from opf/bug/74762-numeric-id-in-the-email-notification-after-adding-watchers

bug/74762 Numeric ID in the email notification after adding watchers
This commit is contained in:
Kabiru Mwenja
2026-05-28 11:38:02 +03:00
committed by GitHub
26 changed files with 1363 additions and 150 deletions
+22 -9
View File
@@ -43,20 +43,25 @@ module OpenProject
# @!macro format_text_params
# @param [Project] project a Project context.
# @param [Boolean] only_path whether to generate links with relative URLs.
# @param [Symbol] render_mode the rendering channel (`:in_app_html`,
# `:external_html`, `:external_text`). Resolves the `only_path`,
# `static_html` and `plain_text` context flags as a set. Prefer this
# over passing the primitives individually. See {RenderMode}.
# @param [Boolean] only_path explicit override for the resolved `only_path`.
# @param [User] current_user the current user context.
# @param [:plain, :rich] format the text format.
# `:plain` will return plain text.
# `:rich` will render raw Markdown as HTML.
# @param ** [Hash] additional context to pass to the underlying rendering
# pipeline.
# pipeline. Explicit `static_html:` / `plain_text:` here override the
# values resolved from `render_mode:`.
# rubocop:disable Layout/LineLength
##
# Formats text according to system settings and provided params.
#
# @overload format_text(text, object: nil, project: @project || object.try(:project), only_path: true, current_user: User.current, format: :rich, **)
# @overload format_text(text, object: nil, project: @project || object.try(:project), render_mode: :in_app_html, only_path: nil, current_user: User.current, format: :rich, **)
#
# @param [String] text the raw text to be formatted, typically Markdown.
# @param [Object] object an object context.
@@ -64,10 +69,10 @@ module OpenProject
#
# @example Setting a project context explicitly
# format_text("## Hello world", project: current_project)
# @example Generating links with full URLs
# format_text("[Projects](/projects)", only_path: false)
# @example Rendering for an external surface (mailer, RSS, export)
# format_text("see #42", render_mode: :external_html)
#
# @overload format_text(object, attribute, project: @project || object.try(:project), only_path: true, current_user: User.current, format: :rich, **)
# @overload format_text(object, attribute, project: @project || object.try(:project), render_mode: :in_app_html, only_path: nil, current_user: User.current, format: :rich, **)
#
# @param [Object] object an object, typically a model
# (i.e. `ActiveRecord::Base` descendent).
@@ -79,7 +84,8 @@ module OpenProject
# format_text(issue, :description, options)
#
# @return [String] the formatted text as an HTML-safe String.
def format_text(*args, object: nil, project: nil, only_path: true, current_user: User.current, format: :rich, **)
def format_text(*args, object: nil, project: nil, render_mode: :in_app_html,
only_path: nil, current_user: User.current, format: :rich, **kwargs)
case args.size
when 1
attribute = nil
@@ -94,15 +100,22 @@ module OpenProject
project ||= @project || object.try(:project)
resolved = RenderMode.resolve(
render_mode,
only_path:,
static_html: kwargs.delete(:static_html),
plain_text: kwargs.delete(:plain_text)
)
Renderer.format_text(
text,
**,
**kwargs,
format:,
object:,
request: try(:request),
current_user:,
attribute:,
only_path:,
**resolved,
project:
)
end
@@ -37,6 +37,8 @@ module OpenProject::TextFormatting
include OpenProject::StaticRouting::UrlHelpers
def call
preload_mentions
doc.search("mention").each do |mention|
anchor = mention_anchor(mention)
mention.replace(anchor) if anchor
@@ -47,6 +49,41 @@ module OpenProject::TextFormatting
private
# WP labels resolve regardless of viewer (so an inaccessible WP
# still renders its current formatted_id); a separate id pluck
# gates anchor-vs-text. Principals collapse the two concerns into
# one visibility-scoped fetch — invisible users and groups fall
# back to the literal envelope text.
def preload_mentions
preload_work_package_mentions
preload_principal_mentions
end
def preload_work_package_mentions
ids = mention_ids_for("work_package")
if ids.empty?
@mentioned_work_packages = {}
@visible_mentioned_ids = Set.new
return
end
scope = WorkPackage.where(id: ids)
scope = scope.includes(:type, :status) if context[:static_html]
@mentioned_work_packages = scope.index_by(&:id)
@visible_mentioned_ids = WorkPackage.visible.where(id: ids).pluck(:id).to_set
end
def preload_principal_mentions
user_ids = mention_ids_for("user")
group_ids = mention_ids_for("group")
@mentioned_users = user_ids.empty? ? {} : User.visible.where(id: user_ids).index_by(&:id)
@mentioned_groups = group_ids.empty? ? {} : Group.visible.where(id: group_ids).index_by(&:id)
end
def mention_ids_for(type)
doc.css(%(mention[data-type="#{type}"])).filter_map { mention_id(it)&.to_i }.uniq
end
def mention_anchor(mention)
mention_instance = class_from_mention(mention)
@@ -75,45 +112,61 @@ module OpenProject::TextFormatting
end
def work_package_mention(work_package, mention)
# Render the mention with the same label and URL convention used for
# `#N` text references elsewhere in the markdown pipeline.
display_id = work_package.display_id
return Nokogiri::XML::Text.new(work_package.formatted_id, mention.document) if text_only?(work_package)
case mention.text.count("#")
when 3
ApplicationController.helpers.content_tag "opce-macro-wp-quickinfo",
"",
data: { id: work_package.id, display_id:, detailed: true }
when 2
ApplicationController.helpers.content_tag "opce-macro-wp-quickinfo",
"",
data: { id: work_package.id, display_id:, detailed: false }
else
link_to(work_package.formatted_id,
work_package_path_or_url(id: display_id, only_path: context[:only_path]),
class: "issue work_package",
data: {
hover_card_trigger_target: "trigger",
hover_card_url: hover_card_work_package_path(display_id)
})
when 3 then work_package_quickinfo(work_package, detailed: true)
when 2 then work_package_quickinfo(work_package, detailed: false)
else work_package_link(work_package)
end
end
def class_from_mention(mention)
mention_class = case mention.attributes["data-type"].value
when "user"
User
when "group"
Group
when "work_package"
WorkPackage
else
raise ArgumentError
end
# The hover-card endpoint a quickinfo would link to is unreachable
# for plain-text recipients and for viewers without view permission.
def text_only?(work_package)
context[:plain_text] || @visible_mentioned_ids.exclude?(work_package.id)
end
mention_class
.visible
.find_by(id: mention_id(mention)) || fallback_text(mention)
def work_package_quickinfo(work_package, detailed:)
return work_package_static_macro(work_package, detailed:) if context[:static_html]
ApplicationController.helpers.content_tag "opce-macro-wp-quickinfo",
"",
data: { id: work_package.id,
display_id: work_package.display_id,
detailed: }
end
# Uses the WP's current `formatted_id` rather than the envelope text,
# so a renamed identifier doesn't leave a stale label in the mailer.
def work_package_static_macro(work_package, detailed:)
label = OpenProject::TextFormatting::Helpers::StaticMacroLabel
.call(work_package, label: work_package.formatted_id, detailed:)
link_to(label,
work_package_path_or_url(id: work_package.display_id, only_path: context[:only_path]),
class: "issue work_package")
end
def work_package_link(work_package)
display_id = work_package.display_id
link_to(work_package.formatted_id,
work_package_path_or_url(id: display_id, only_path: context[:only_path]),
class: "issue work_package",
data: {
hover_card_trigger_target: "trigger",
hover_card_url: hover_card_work_package_path(display_id)
})
end
def class_from_mention(mention)
id = mention_id(mention)&.to_i
case mention.attributes["data-type"].value
when "user" then @mentioned_users[id]
when "group" then @mentioned_groups[id]
when "work_package" then @mentioned_work_packages[id]
else raise ArgumentError
end || fallback_text(mention)
end
##
@@ -0,0 +1,41 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module OpenProject::TextFormatting
module Filters
# Terminal stage when the rich pipeline is rendering for `text/plain`
# bodies — collapses the DOM to its visible text so no HTML escapes.
class PlainTextOutputFilter < HTML::Pipeline::Filter
def call
doc.text
end
end
end
end
@@ -31,11 +31,45 @@ require "task_list/filter"
module OpenProject::TextFormatting::Formats::Markdown
class Formatter < OpenProject::TextFormatting::Formats::BaseFormatter
RICH_FILTERS = [
OpenProject::TextFormatting::Filters::SettingMacrosFilter,
OpenProject::TextFormatting::Filters::MarkdownFilter,
OpenProject::TextFormatting::Filters::SanitizationFilter,
OpenProject::TextFormatting::Filters::TaskListFilter,
OpenProject::TextFormatting::Filters::TableOfContentsFilter,
OpenProject::TextFormatting::Filters::MacroFilter,
OpenProject::TextFormatting::Filters::MentionFilter,
OpenProject::TextFormatting::Filters::PatternMatcherFilter,
OpenProject::TextFormatting::Filters::SyntaxHighlightFilter,
OpenProject::TextFormatting::Filters::AttachmentFilter,
OpenProject::TextFormatting::Filters::AutolinkFilter,
OpenProject::TextFormatting::Filters::AutolinkCustomProtocolsFilter,
OpenProject::TextFormatting::Filters::RelativeLinkFilter,
OpenProject::TextFormatting::Filters::LinkAttributeFilter,
OpenProject::TextFormatting::Filters::ExternalLinkCaptureFilter,
OpenProject::TextFormatting::Filters::FigureWrappedFilter,
OpenProject::TextFormatting::Filters::BemCssFilter
].freeze
# `text/plain` mailer bodies share the matcher and mention stages so
# work-package references resolve consistently with the HTML channel,
# then `PlainTextOutputFilter` collapses the DOM to text. Filters that
# only shape HTML (TOC, syntax highlight, autolink, link-attribute,
# figure, BEM) are omitted because `doc.text` would discard their work.
TEXT_FILTERS = [
OpenProject::TextFormatting::Filters::SettingMacrosFilter,
OpenProject::TextFormatting::Filters::MarkdownFilter,
OpenProject::TextFormatting::Filters::SanitizationFilter,
OpenProject::TextFormatting::Filters::MentionFilter,
OpenProject::TextFormatting::Filters::PatternMatcherFilter,
OpenProject::TextFormatting::Filters::PlainTextOutputFilter
].freeze
def to_html(text)
result = pipeline.call(text, context)
output = result[:output].to_s
output.html_safe
context[:plain_text] ? output : output.html_safe # rubocop:disable Rails/OutputSafety
end
def to_document(text)
@@ -43,25 +77,7 @@ module OpenProject::TextFormatting::Formats::Markdown
end
def filters
[
OpenProject::TextFormatting::Filters::SettingMacrosFilter,
OpenProject::TextFormatting::Filters::MarkdownFilter,
OpenProject::TextFormatting::Filters::SanitizationFilter,
OpenProject::TextFormatting::Filters::TaskListFilter,
OpenProject::TextFormatting::Filters::TableOfContentsFilter,
OpenProject::TextFormatting::Filters::MacroFilter,
OpenProject::TextFormatting::Filters::MentionFilter,
OpenProject::TextFormatting::Filters::PatternMatcherFilter,
OpenProject::TextFormatting::Filters::SyntaxHighlightFilter,
OpenProject::TextFormatting::Filters::AttachmentFilter,
OpenProject::TextFormatting::Filters::AutolinkFilter,
OpenProject::TextFormatting::Filters::AutolinkCustomProtocolsFilter,
OpenProject::TextFormatting::Filters::RelativeLinkFilter,
OpenProject::TextFormatting::Filters::LinkAttributeFilter,
OpenProject::TextFormatting::Filters::ExternalLinkCaptureFilter,
OpenProject::TextFormatting::Filters::FigureWrappedFilter,
OpenProject::TextFormatting::Filters::BemCssFilter
]
context[:plain_text] ? TEXT_FILTERS : RICH_FILTERS
end
def self.format
@@ -0,0 +1,46 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module OpenProject::TextFormatting
module Helpers
# Anchor text for the static-HTML form of a WP quickinfo macro:
# `[status ]type label: subject`. Used in channels (HTML mailers,
# server-side previews) that can't hydrate the `<opce-*>` widget.
module StaticMacroLabel
def self.call(work_package, label:, detailed:)
parts = []
parts << work_package.status&.name if detailed
parts << work_package.type&.name
parts << label
"#{parts.compact.join(' ')}: #{work_package.subject}"
end
end
end
end
@@ -78,41 +78,56 @@ module OpenProject::TextFormatting::Matchers
# Both quickinfo and plain link need the WP record so the rendered
# HTML can carry the record id in `data-id`. Unresolved WP →
# literal text rather than a broken reference.
wp = OpenProject::TextFormatting::Matchers::ResourceLinksMatcher.work_package_for(display_id)
wp = preload_cache.fetch(display_id)
return nil unless wp
if quickinfo?
render_work_package_macro(id: wp.id, display_id: wp.display_id, detailed: detailed?)
render_work_package_macro(work_package: wp, fallback_id: display_id, detailed: detailed?)
else
render_work_package_link(wp, fallback_id: display_id)
end
end
def render_for_numeric(wp_id)
wp = OpenProject::TextFormatting::Matchers::ResourceLinksMatcher.work_package_for(wp_id)
wp = preload_cache.fetch(wp_id)
if quickinfo?
# Prefer the resolved WP's identifiers; fall back to the matched
# id when no preload is available (classic mode).
record_id = wp&.id || wp_id
display_id = wp&.display_id || wp_id
render_work_package_macro(id: record_id, display_id:, detailed: detailed?)
render_work_package_macro(work_package: wp, fallback_id: wp_id, detailed: detailed?)
else
render_work_package_link(wp, fallback_id: wp_id)
end
end
def render_work_package_macro(id:, display_id:, detailed: false)
def render_work_package_macro(work_package:, fallback_id:, detailed: false)
id = work_package&.id || fallback_id
display_id = work_package&.display_id || fallback_id
label = WorkPackage::SemanticIdentifier.format_display_id(display_id)
return label if text_only?(work_package)
return render_static_work_package_macro(work_package, label, detailed:) if context[:static_html]
ApplicationController.helpers.content_tag "opce-macro-wp-quickinfo",
"",
data: { id:, display_id:, detailed: }
end
# The label keeps what the author wrote (possibly a historical
# alias) so the rendered text matches the source markdown.
def render_static_work_package_macro(work_package, label, detailed:)
return label unless work_package
link_to(OpenProject::TextFormatting::Helpers::StaticMacroLabel.call(work_package, label:, detailed:),
work_package_path_or_url(id: work_package.display_id, only_path: context[:only_path]),
class: "issue work_package")
end
def render_work_package_link(work_package, fallback_id:)
# Fall back to the bare `#N` shape when no WP is provided (classic mode,
# render path bypassing `PatternMatcherFilter`) rather than running a
# per-link query inside the renderer.
label = work_package&.formatted_id || "##{fallback_id}"
return label if text_only?(work_package)
href_id = work_package&.display_id || fallback_id
link_to(label,
@@ -123,6 +138,16 @@ module OpenProject::TextFormatting::Matchers
hover_card_url: hover_card_work_package_path(href_id)
})
end
# A nil WP means classic mode skipped the preload, or the reference
# didn't resolve — neither case needs visibility gating.
def text_only?(work_package)
context[:plain_text] || (work_package && !preload_cache.visible?(work_package.id))
end
def preload_cache
OpenProject::TextFormatting::Matchers::ResourceLinksMatcher.current_cache
end
end
end
end
@@ -69,8 +69,31 @@ module OpenProject::TextFormatting
# identifier:version:1.0.0
# identifier:source:some/file
class ResourceLinksMatcher < RegexMatcher
WORK_PACKAGES_LOOKUP_KEY = :text_formatting_work_packages_lookup
private_constant :WORK_PACKAGES_LOOKUP_KEY
# Unscoped label resolution (`lookup`) paired with viewer-scoped
# link gating (`visible_ids`), so the link handler renders the
# same label for everyone and decides anchor-vs-text per viewer.
class WorkPackagePreloadCache
attr_reader :lookup, :visible_ids
def initialize(lookup:, visible_ids:)
@lookup = lookup
@visible_ids = visible_ids
end
def fetch(identifier)
lookup[identifier.to_s]
end
def visible?(work_package_id)
visible_ids.include?(work_package_id)
end
# Frozen singleton, not a factory — callers must not mutate it.
EMPTY = new(lookup: {}.freeze, visible_ids: Set.new.freeze).freeze
end
CACHE_KEY = :text_formatting_work_package_preload_cache
private_constant :CACHE_KEY
include ::OpenProject::TextFormatting::Truncation
# used for the work package quick links
@@ -130,33 +153,33 @@ module OpenProject::TextFormatting
]
end
# Returns the preloaded WorkPackage for the given identifier (numeric
# or semantic), or nil if no preload is active (classic mode, no `#N`
# references) or the WP couldn't be resolved. Lookup keys are always
# strings — see `index_by_id_and_identifier`.
def self.work_package_for(identifier)
RequestStore.store.dig(WORK_PACKAGES_LOOKUP_KEY, identifier.to_s)
def self.current_cache
RequestStore.store[CACHE_KEY] || WorkPackagePreloadCache::EMPTY
end
# Doc-level preload called by `PatternMatcherFilter`. Save/restores
# the lookup so a nested `format_text` (e.g. custom-field formatter
# re-entering the pipeline) doesn't clobber the outer render. Classic
# mode skips the load — `display_id` collapses to numeric, so the
# link handler can render from the matched id alone.
def self.with_preloaded_resources(doc, _context)
previous = RequestStore.store[WORK_PACKAGES_LOOKUP_KEY]
return yield unless Setting::WorkPackageIdentifier.semantic?
# Save/restore so a nested `format_text` (e.g. a custom-field
# formatter re-entering the pipeline) doesn't clobber the outer
# render's cache.
def self.with_preloaded_resources(doc, context)
previous = RequestStore.store[CACHE_KEY]
return yield unless preload_required?(context)
identifiers = collect_work_package_identifiers(doc)
return yield if identifiers.empty?
RequestStore.store[WORK_PACKAGES_LOOKUP_KEY] = build_lookup(identifiers)
RequestStore.store[CACHE_KEY] = build_cache(identifiers, context)
yield
ensure
RequestStore.store[WORK_PACKAGES_LOOKUP_KEY] = previous
RequestStore.store[CACHE_KEY] = previous
end
# Semantic mode needs the row to map `PROJ-7` to an id; static-HTML
# output needs `type`/`subject` to compose the quickinfo anchor.
def self.preload_required?(context)
Setting::WorkPackageIdentifier.semantic? || context[:static_html]
end
private_class_method :preload_required?
def self.collect_work_package_identifiers(doc)
identifiers = Set.new
doc.search(".//text()").each do |node|
@@ -182,19 +205,25 @@ module OpenProject::TextFormatting
identifier
end
# 1 SELECT in the common case. A second targeted SELECT fires for
# historical aliases — the loaded WP row carries only its current
# identifier, so unmapped inputs must be filled in from
# `WorkPackageSemanticAlias`. The visible-id set passes through
# to the alias fold-in explicitly so each query enforces
# visibility at its own boundary, leaving no implicit trust for
# the cache to leak through.
def self.build_lookup(identifiers)
work_packages = WorkPackage.visible.where_display_id_in(*identifiers).select(:id, :identifier).to_a
# Two SELECTs in the common case (unscoped fetch + visibility id
# pluck), a third when historical aliases need resolving. Static-
# HTML output additionally needs `:type` and `:status` to compose
# the anchor for `##`/`###` macros.
def self.build_cache(identifiers, context = {})
scope = WorkPackage.where_display_id_in(*identifiers)
scope = if context[:static_html]
scope.includes(:type, :status)
else
scope.select(:id, :identifier)
end
work_packages = scope.to_a
all_wp_ids = work_packages.map(&:id)
visible_ids = WorkPackage.visible.where(id: all_wp_ids).pluck(:id).to_set
lookup = index_by_id_and_identifier(work_packages)
fold_in_alias_keys(lookup, identifiers, visible_wp_ids: work_packages.map(&:id))
lookup
fold_in_alias_keys(lookup, identifiers, all_wp_ids:)
WorkPackagePreloadCache.new(lookup:, visible_ids:)
end
private_class_method :build_cache
# Keys are stringified at write time so callers can read with a single
# `identifier.to_s` regardless of whether the input is a numeric id or
@@ -207,12 +236,12 @@ module OpenProject::TextFormatting
end
private_class_method :index_by_id_and_identifier
def self.fold_in_alias_keys(lookup, identifiers, visible_wp_ids:)
def self.fold_in_alias_keys(lookup, identifiers, all_wp_ids:)
unmapped = identifiers.map(&:to_s) - lookup.keys
return if unmapped.empty? || visible_wp_ids.empty?
return if unmapped.empty? || all_wp_ids.empty?
WorkPackageSemanticAlias
.where(work_package_id: visible_wp_ids, identifier: unmapped)
.where(work_package_id: all_wp_ids, identifier: unmapped)
.pluck(:identifier, :work_package_id)
.each { |ident, wp_id| lookup[ident] = lookup[wp_id.to_s] }
end
@@ -0,0 +1,64 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module OpenProject
module TextFormatting
# Maps a high-level rendering channel (`:in_app_html`, `:external_html`,
# `:external_text`) onto the primitive `only_path` / `static_html` /
# `plain_text` context flags that the filter pipeline reads.
#
# External surfaces always need absolute URLs *and* static rendering for
# JS-dependent components — the two flags are a coupled set. A single
# mode value is the canonical API; the primitives stay available as
# per-flag escape hatches for callers that need an asymmetric mix.
module RenderMode
DEFAULTS = {
in_app_html: { only_path: true, static_html: false, plain_text: false }.freeze,
external_html: { only_path: false, static_html: true, plain_text: false }.freeze,
external_text: { only_path: false, static_html: false, plain_text: true }.freeze
}.freeze
module_function
def resolve(mode, only_path: nil, static_html: nil, plain_text: nil)
defaults = DEFAULTS.fetch(mode) do
raise ArgumentError, "Unknown render_mode: #{mode.inspect}. " \
"Expected one of #{DEFAULTS.keys.inspect}."
end
{
only_path: only_path.nil? ? defaults[:only_path] : only_path,
static_html: static_html.nil? ? defaults[:static_html] : static_html,
plain_text: plain_text.nil? ? defaults[:plain_text] : plain_text
}
end
end
end
end