diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 3040bccf..61febd2c 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config --exclude-limit 10000` -# on 2025-04-10 07:56:29 UTC using RuboCop version 1.75.2. +# on 2025-07-25 08:41:37 UTC using RuboCop version 1.79.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -44,7 +44,7 @@ Layout/ExtraSpacing: - 'spec/inertia/request_spec.rb' - 'spec/inertia/response_spec.rb' -# Offense count: 2 +# Offense count: 16 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: IndentationWidth. # SupportedStyles: special_inside_parentheses, consistent, align_braces @@ -184,8 +184,8 @@ Style/BlockDelimiters: # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: EnforcedStyle, EnforcedStyleForClasses, EnforcedStyleForModules. # SupportedStyles: nested, compact -# SupportedStylesForClasses: , nested, compact -# SupportedStylesForModules: , nested, compact +# SupportedStylesForClasses: ~, nested, compact +# SupportedStylesForModules: ~, nested, compact Style/ClassAndModuleChildren: Exclude: - 'lib/inertia_rails/helper.rb' @@ -404,7 +404,7 @@ Style/SoleNestedConditional: Exclude: - 'lib/inertia_rails/controller.rb' -# Offense count: 78 +# Offense count: 81 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline. # SupportedStyles: single_quotes, double_quotes @@ -434,7 +434,7 @@ Style/StringLiterals: - 'spec/inertia/rspec_helper_spec.rb' - 'spec/rails_helper.rb' -# Offense count: 2 +# Offense count: 3 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: . # SupportedStyles: percent, brackets @@ -466,9 +466,9 @@ Style/TrailingCommaInHashLiteral: - 'spec/inertia/response_spec.rb' - 'spec/inertia/rspec_helper_spec.rb' -# Offense count: 19 +# Offense count: 32 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings. +# Configuration parameters: AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings. # URISchemes: http, https Layout/LineLength: Max: 276 diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 891579d5..ab5d373f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -167,6 +167,15 @@ export default defineConfig({ { text: 'Inertia Modal', link: '/cookbook/inertia-modal' }, ], }, + { + text: 'Inertia-Rails-Only Features', + items: [ + { + text: 'Server Managed Meta Tags', + link: '/cookbook/server-managed-meta-tags', + }, + ], + }, { text: 'Troubleshooting', items: [ diff --git a/docs/cookbook/server-managed-meta-tags.md b/docs/cookbook/server-managed-meta-tags.md new file mode 100644 index 00000000..9c01eda8 --- /dev/null +++ b/docs/cookbook/server-managed-meta-tags.md @@ -0,0 +1,361 @@ +# Server Managed Meta Tags + +Inertia Rails can manage a page's meta tags on the server instead of on the frontend. This means that link previews (such as on Facebook, LinkedIn, etc.) will include correct meta _without server-side rendering_. + +Inertia Rails renders server defined meta tags into both the server rendered HTML and the client-side Inertia page props. Because the tags share unique `head-key` attributes, the client will "take over" the meta tags after the initial page load. + +@available_since rails=3.10.0 + +## Setup + +### Server Side + +Simply add the `inertia_meta_tags` helper to your layout. This will render the meta tags in the `` section of your HTML. + +```erb + + + + + + ... + <%= inertia_meta_tags %> // [!code ++] + My Inertia App // [!code --] + + +``` + +> [!NOTE] +> Make sure to remove the `` tag in your Rails layout if you plan to manage it with Inertia Rails. Otherwise you will end up with duplicate `<title>` tags. + +### Client Side + +Copy the following code into your application. It should be rendered **once** in your application, such as in a [layout component +](/guide/pages#creating-layouts). + +:::tabs key:frameworks +== Vue + +```vue +<script> +import { Head } from '@inertiajs/vue3' +import { usePage } from '@inertiajs/vue3' +import { h } from 'vue' + +export default { + name: 'MetaTags', + setup() { + const page = usePage() + + return () => { + const metaTags = page.props._inertia_meta || [] + + return h(Head, {}, () => + metaTags.map((meta) => { + const { tagName, innerContent, headKey, httpEquiv, ...attrs } = meta + + const attributes = { + key: headKey, + 'head-key': headKey, + ...attrs, + } + + if (httpEquiv) { + attributes['http-equiv'] = httpEquiv + } + + let content = null + if (innerContent != null) { + content = + typeof innerContent === 'string' + ? innerContent + : JSON.stringify(innerContent) + } + + return h(tagName, attributes, content) + }), + ) + } + }, +} +</script> +``` + +== React + +```jsx +import React from 'react' +import { Head, usePage } from '@inertiajs/react' + +const MetaTags = () => { + const { _inertia_meta: meta } = usePage().props + return ( + <Head> + {meta.map((meta) => { + const { tagName, innerContent, headKey, httpEquiv, ...attrs } = meta + + let stringifiedInnerContent + if (innerContent != null) { + stringifiedInnerContent = + typeof innerContent === 'string' + ? innerContent + : JSON.stringify(innerContent) + } + + return React.createElement(tagName, { + key: headKey, + 'head-key': headKey, + ...(httpEquiv ? { 'http-equiv': httpEquiv } : {}), + ...attrs, + ...(stringifiedInnerContent + ? { dangerouslySetInnerHTML: { __html: stringifiedInnerContent } } + : {}), + }) + })} + </Head> + ) +} + +export default MetaTags +``` + +== Svelte 4|Svelte 5 + +```svelte +<!-- MetaTags.svelte --> +<script> + import { onMount } from 'svelte' + import { page } from '@inertiajs/svelte' + + $: metaTags = ($page.props._inertia_meta ?? []).map( + ({ tagName, headKey, innerContent, httpEquiv, ...attrs }) => ({ + tagName, + headKey, + innerContent, + attrs: httpEquiv ? { ...attrs, 'http-equiv': httpEquiv } : attrs, + }), + ) + + // Svelte throws warnings if we render void elements like meta with content + $: voidTags = metaTags.filter((tag) => tag.innerContent == null) + $: contentTags = metaTags.filter((tag) => tag.innerContent != null) + + let ready = false + + onMount(() => { + // Clean up server-rendered tags + document.head.querySelectorAll('[inertia]').forEach((el) => el.remove()) + + ready = true + }) +</script> + +<svelte:head> + {#if ready} + <!-- Void elements (no content) --> + {#each voidTags as tag (tag.headKey)} + <svelte:element this={tag.tagName} inertia={tag.headKey} {...tag.attrs} /> + {/each} + + <!-- Elements with content --> + {#each contentTags as tag (tag.headKey)} + <svelte:element this={tag.tagName} inertia={tag.headKey} {...tag.attrs}> + {@html typeof tag.innerContent === 'string' + ? tag.innerContent + : JSON.stringify(tag.innerContent)} + </svelte:element> + {/each} + {/if} +</svelte:head> +``` + +::: + +## Rendering Meta Tags + +Tags are defined as plain hashes and conform to the following structure: + +```ruby +# All fields are optional. +{ + # Defaults to "meta" if not provided + tag_name: "meta", + + # Used for <meta http-equiv="..."> + http_equiv: "Content-Security-Policy", + + # Used to deduplicate tags. InertiaRails will auto-generate one if not provided + head_key: "csp-header", + + # Used with <script>, <title>, etc. + inner_content: "Some content", + + # Any additional attributes will be passed directly to the tag. + # For example: name: "description", content: "Page description" + name: "description", + content: "A description of the page" +} +``` + +The `<title>` tag has shortcut syntax: + +```ruby +{ title: "The page title" } +``` + +### In the renderer + +Add meta tags to an action by passing an array of hashes to the `meta:` option in the `render` method: + +```ruby +class EventsController < ApplicationController + def show + event = Event.find(params[:id]) + + render inertia: 'Event/Show', props: { event: event.as_json }, meta: [ + { title: "Check out the #{event.name} event!" }, + { name: 'description', content: event.description }, + { tag_name: 'script', type: 'application/ld+json', inner_content: { '@context': 'https://schema.org', '@type': 'Event', name: 'My Event' } } + ] + end +end +``` + +### Shared Meta Tags + +Often, you will want to define default meta tags that are shared across certain pages and which you can override within a specific controller or action. Inertia Rails has an `inertia_meta` controller instance method which references a store of meta tag data. + +You can call it anywhere in a controller to manage common meta tags, such as in `before_action` callbacks or directly in an action. + +```ruby +class EventsController < ApplicationController + before_action :set_meta_tags + + def show + render inertia: 'Event/Show', props: { event: Event.find(params[:id]) } + end + + private + + def set_meta_tags + inertia_meta.add([ + { title: 'Look at this event!' } + ]) + end +end +``` + +#### The `inertia_meta` API + +The `inertia_meta` method provides a simple API to manage your meta tags. You can add, remove, or clear tags as needed. The `inertia_meta.remove` method accepts either a `head_key` string or a block to filter tags. + +```ruby +# Add a single tag +inertia_meta.add({ title: 'Some Page title' }) + +# Add multiple tags at once +inertia_meta.add([ + { tag_name: 'meta', name: 'og:description', content: 'A description of the page' }, + { tag_name: 'meta', name: 'twitter:title', content: 'A title for Twitter' }, + { tag_name: 'title', inner_content: 'A title for the page', head_key: 'my_custom_head_key' }, + { tag_name: 'script', type: 'application/ld+json', inner_content: { '@context': 'https://schema.org', '@type': 'Event', name: 'My Event' } } +]) + +# Remove a specific tag by head_key +inertia_meta.remove("my_custom_head_key") + +# Remove tags by a condition +inertia_meta.remove do |tag| + tag[:tag_name] == 'script' && tag[:type] == 'application/ld+json' +end + +# Remove all tags +inertia_meta.clear +``` + +#### JSON-LD and Script Tags + +Inertia Rails supports defining `<script>` tags with `type="application/ld+json"` for structured data. All other script tags will be marked as `type="text/plain"` to prevent them from executing on the client side. Executable scripts should be added either in the Rails layout or using standard techniques in your frontend framework. + +```ruby +inertia_meta.add({ + tag_name: "script", + type: "application/ld+json", + inner_content: { + "@context": "https://schema.org", + "@type": "Event", + name: "My Event", + startDate: "2023-10-01T10:00:00Z", + location: { + "@type": "Place", + name: "Event Venue", + address: "123 Main St, City, Country" + } + } +}) +``` + +## Deduplication + +> [!NOTE] +> The Svelte adapter does not have a `<Head />` component. Inertia Rails will deduplicate meta tags _on the server_, and the Svelte component above will render them deduplicated accordingly. + +### Automatic Head Keys + +Inertia Rails relies on the `head-key` attribute and the `<Head />` components that the Inertia.js core uses to [manage meta tags](/guide/title-and-meta) and deduplicate them. Inertia.js core expects us to manage `head-key` attributes and deduplication manually, but Inertia Rails will generate them automatically for you. + +- `<meta>` tags will use the `name`,`property`, or `http_equiv` attributes to generate a head key. This enables automatic deduplication of common meta tags like `description`, `og:title`, and `twitter:card`. +- All other tags will deterministically generate a `head-key` based on the tag's attributes. + +#### Allowing Duplicates + +Sometimes, it is valid HTML to have multiple meta tags with the same name or property. If you want to allow duplicates, you can set the `allow_duplicates` option to `true` when defining the tag. + +```ruby +class StoriesController < ApplicationController + before_action do + inertia_meta.add({ name: 'article:author', content: 'Tony Gilroy' }) + end + + # Renders a single article:author meta tag + def single_author + render inertia: 'Stories/Show' + end + + # Renders multiple article:author meta tags + def multiple_authors + render inertia: 'Stories/Show', meta: [ + { name: 'article:author', content: 'Dan Gilroy', allow_duplicates: true }, + ] + end +end +``` + +### Manual Head Keys + +Automatic head keys should cover the majority of use cases, but you can set `head_key` manually if you need to control the deduplication behavior more precisely. For example, you may want to do this if you know you will remove a shared meta tag in a specific action. + +```ruby +# In a concern or `before_action` callback +inertia_meta.add([ + { + tag_name: 'meta', + name: 'description', + content: 'A description of the page', + head_key: 'my_custom_head_key' + }, +]) + +# Later in a specific action +inertia_meta.remove('my_custom_head_key') +``` + +## Combining Meta Tag Methods + +There are multiple ways to manage meta tags in Inertia Rails: + +- Adding tags to a Rails layout such as `application.html.erb`. +- Using the `<Head />` component from Inertia.js (or the Svelte head element) in the frontend. +- Using the server driven meta tags feature described here. + +Nothing prevents you from using these together, but for organizational purposes, we recommended using only one of the last two techniques. diff --git a/docs/guide/title-and-meta.md b/docs/guide/title-and-meta.md index 00745ded..8cecdeef 100644 --- a/docs/guide/title-and-meta.md +++ b/docs/guide/title-and-meta.md @@ -2,6 +2,9 @@ Since Inertia powered JavaScript apps are rendered within the document `<body>`, they are unable to render markup to the document `<head>`, as it's outside of their scope. To help with this, Inertia ships with a `<Head>` component which can be used to set the page `<title>`, `<meta>` tags, and other `<head>` elements. +> [!NOTE] +> Since v3.10.0, Inertia Rails supports managing meta tags via Rails. This allows your meta tags to work with link preview services without setting up server-side rendering. Since this isn't a part of the Inertia.js core, it's documented in the [server driven meta tags cookbook](/cookbook/server-managed-meta-tags). + > [!NOTE] > The `<Head>` component will only replace `<head>` elements that are not in your server-side layout. diff --git a/lib/inertia_rails.rb b/lib/inertia_rails.rb index a7431b1b..54227e50 100644 --- a/lib/inertia_rails.rb +++ b/lib/inertia_rails.rb @@ -15,11 +15,7 @@ request, response, method(:render), - props: options[:props], - view_data: options[:view_data], - deep_merge: options[:deep_merge], - encrypt_history: options[:encrypt_history], - clear_history: options[:clear_history] + **options ).render end diff --git a/lib/inertia_rails/controller.rb b/lib/inertia_rails/controller.rb index fba698f0..d877a817 100644 --- a/lib/inertia_rails/controller.rb +++ b/lib/inertia_rails/controller.rb @@ -1,6 +1,7 @@ require_relative "inertia_rails" require_relative "helper" require_relative "action_filter" +require_relative "meta_tag_builder" module InertiaRails module Controller @@ -127,6 +128,10 @@ def redirect_to(options = {}, response_options = {}) super end + def inertia_meta + @inertia_meta ||= InertiaRails::MetaTagBuilder.new(self) + end + private def inertia_view_assigns diff --git a/lib/inertia_rails/helper.rb b/lib/inertia_rails/helper.rb index d05a2f8e..eb25339d 100644 --- a/lib/inertia_rails/helper.rb +++ b/lib/inertia_rails/helper.rb @@ -15,4 +15,18 @@ def inertia_headers def inertia_rendering? controller.instance_variable_get("@_inertia_rendering") end + + def inertia_page + controller.instance_variable_get("@_inertia_page") + end + + def inertia_meta_tags + meta_tag_data = (inertia_page || {}).dig(:props, :_inertia_meta) || [] + + meta_tags = meta_tag_data.map do |inertia_meta_tag| + inertia_meta_tag.to_tag(tag) + end + + safe_join(meta_tags, "\n") + end end diff --git a/lib/inertia_rails/inertia_rails.rb b/lib/inertia_rails/inertia_rails.rb index f06bf8b2..47d5837a 100644 --- a/lib/inertia_rails/inertia_rails.rb +++ b/lib/inertia_rails/inertia_rails.rb @@ -8,6 +8,7 @@ require 'inertia_rails/defer_prop' require 'inertia_rails/merge_prop' require 'inertia_rails/configuration' +require 'inertia_rails/meta_tag' module InertiaRails class << self diff --git a/lib/inertia_rails/meta_tag.rb b/lib/inertia_rails/meta_tag.rb new file mode 100644 index 00000000..007ffd0c --- /dev/null +++ b/lib/inertia_rails/meta_tag.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module InertiaRails + class MetaTag + # See https://github.com/rails/rails/blob/v8.0.0/actionview/lib/action_view/helpers/tag_helper.rb#L84-L97 + UNARY_TAGS = %i[ + area base br col embed hr img input keygen link meta source track wbr + ].freeze + + LD_JSON_TYPE = 'application/ld+json' + DEFAULT_SCRIPT_TYPE = 'text/plain' + + GENERATABLE_HEAD_KEY_PROPERTIES = %i[name property http_equiv].freeze + + def initialize(tag_name: nil, head_key: nil, allow_duplicates: false, type: nil, **tag_data) + if shortened_title_tag?(tag_name, tag_data) + @tag_name = :title + @tag_data = { inner_content: tag_data[:title] } + else + @tag_name = tag_name.nil? ? :meta : tag_name.to_sym + @tag_data = tag_data.symbolize_keys + end + @tag_type = determine_tag_type(type) + @allow_duplicates = allow_duplicates + @head_key = @tag_name == :title ? 'title' : (head_key || generate_head_key) + end + + def as_json(_options = nil) + { + tagName: @tag_name, + headKey: @head_key, + type: @tag_type, + }.tap do |result| + result.merge!(@tag_data.transform_keys { |k| k.to_s.camelize(:lower).to_sym }) + result.compact_blank! + end + end + + def to_tag(tag_helper) + data = @tag_data.merge(type: @tag_type, inertia: @head_key) + + inner_content = + if @tag_name == :script + tag_script_inner_content(data.delete(:inner_content)) + else + data.delete(:inner_content) + end + + if UNARY_TAGS.include? @tag_name + tag_helper.public_send(@tag_name, **data.transform_keys { |k| k.to_s.tr('_', '-').to_sym }) + else + tag_helper.public_send(@tag_name, inner_content, **data.transform_keys { |k| k.to_s.tr('_', '-').to_sym }) + end + end + + def [](key) + key = key.to_sym + return @tag_name if key == :tag_name + return @head_key if key == :head_key + return @tag_type if key == :type + + @tag_data[key] + end + + private + + def tag_script_inner_content(content) + case content + when Hash, Array + ERB::Util.json_escape(content.to_json).html_safe + else + content + end + end + + def shortened_title_tag?(tag_name, tag_data) + tag_name.nil? && tag_data.keys == [:title] + end + + def determine_tag_type(type) + return type unless @tag_name == :script + + type == LD_JSON_TYPE ? LD_JSON_TYPE : DEFAULT_SCRIPT_TYPE + end + + def generate_head_key + generate_meta_head_key || "#{@tag_name}-#{tag_digest}" + end + + def tag_digest + signature = @tag_data.sort.map { |k, v| "#{k}=#{v}" }.join('&') + Digest::MD5.hexdigest(signature)[0, 8] + end + + def generate_meta_head_key + return unless @tag_name == :meta + return 'meta-charset' if @tag_data.key?(:charset) + + GENERATABLE_HEAD_KEY_PROPERTIES.each do |key| + next unless @tag_data.key?(key) + + return [ + 'meta', + key, + @tag_data[key].parameterize, + @allow_duplicates ? tag_digest : nil + ].compact.join('-') + end + + nil + end + end +end diff --git a/lib/inertia_rails/meta_tag_builder.rb b/lib/inertia_rails/meta_tag_builder.rb new file mode 100644 index 00000000..e0847319 --- /dev/null +++ b/lib/inertia_rails/meta_tag_builder.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module InertiaRails + class MetaTagBuilder + def initialize(controller) + @controller = controller + @meta_tags = {} + end + + def meta_tags + @meta_tags.values + end + + def add(meta_tag) + if meta_tag.is_a?(Array) + meta_tag.each { |tag| add(tag) } + elsif meta_tag.is_a?(Hash) + add_new_tag(meta_tag) + else + raise ArgumentError, 'Meta tag must be a Hash or Array of Hashes' + end + + self + end + + def remove(head_key = nil, &block) + raise ArgumentError, 'Cannot provide both head_key and a block' if head_key && block_given? + raise ArgumentError, 'Must provide either head_key or a block' if head_key.nil? && !block_given? + + if head_key + @meta_tags.delete(head_key) + else + @meta_tags.reject! { |_, tag| block.call(tag) } + end + + self + end + + def clear + @meta_tags.clear + + self + end + + private + + def add_new_tag(new_tag_data) + new_tag = InertiaRails::MetaTag.new(**new_tag_data) + @meta_tags[new_tag[:head_key]] = new_tag + end + end +end diff --git a/lib/inertia_rails/renderer.rb b/lib/inertia_rails/renderer.rb index 641ddeeb..12e5bb20 100644 --- a/lib/inertia_rails/renderer.rb +++ b/lib/inertia_rails/renderer.rb @@ -16,9 +16,8 @@ class Renderer :clear_history ) - def initialize(component, controller, request, response, render_method, props: nil, view_data: nil, - deep_merge: nil, encrypt_history: nil, clear_history: nil) - if component.is_a?(Hash) && !props.nil? + def initialize(component, controller, request, response, render_method, **options) + if component.is_a?(Hash) && options.key?(:props) raise ArgumentError, 'Parameter `props` is not allowed when passing a Hash as the first argument' end @@ -29,12 +28,13 @@ def initialize(component, controller, request, response, render_method, props: n @request = request @response = response @render_method = render_method - @props = props || (component.is_a?(Hash) ? component : controller.__send__(:inertia_view_assigns)) - @view_data = view_data || {} - @deep_merge = deep_merge.nil? ? configuration.deep_merge_shared_data : deep_merge - @encrypt_history = encrypt_history.nil? ? configuration.encrypt_history : encrypt_history - @clear_history = clear_history || controller.session[:inertia_clear_history] || false + @props = options.fetch(:props, component.is_a?(Hash) ? component : controller.__send__(:inertia_view_assigns)) + @view_data = options.fetch(:view_data, {}) + @deep_merge = options.fetch(:deep_merge, configuration.deep_merge_shared_data) + @encrypt_history = options.fetch(:encrypt_history, configuration.encrypt_history) + @clear_history = options.fetch(:clear_history, controller.session[:inertia_clear_history] || false) @controller.instance_variable_set('@_inertia_rendering', true) + controller.inertia_meta.add(options[:meta]) if options[:meta] end def render @@ -52,6 +52,7 @@ def render rescue StandardError nil end + controller.instance_variable_set('@_inertia_page', page) @render_method.call template: 'inertia', layout: layout, locals: view_data.merge(page: page) end end @@ -90,7 +91,9 @@ def merge_props(shared_props, props) def computed_props merged_props = merge_props(shared_data, props) - deep_transform_props(merged_props) + deep_transform_props(merged_props).tap do |transformed_props| + transformed_props[:_inertia_meta] = meta_tags if meta_tags.present? + end end def page @@ -215,5 +218,9 @@ def excluded_by_only_partial_keys?(path_with_prefixes) def excluded_by_except_partial_keys?(path_with_prefixes) partial_except_keys.present? && (path_with_prefixes & partial_except_keys).any? end + + def meta_tags + controller.inertia_meta.meta_tags + end end end diff --git a/lib/inertia_rails/rspec.rb b/lib/inertia_rails/rspec.rb index 17fffd07..033e3b1e 100644 --- a/lib/inertia_rails/rspec.rb +++ b/lib/inertia_rails/rspec.rb @@ -75,7 +75,7 @@ def inertia_tests_setup? config.before(:each, inertia: true) do new_renderer = InertiaRails::Renderer.method(:new) allow(InertiaRails::Renderer).to receive(:new) do |component, controller, request, response, render, named_args| - new_renderer.call(component, controller, request, response, inertia_wrap_render(render), **named_args) + new_renderer.call(component, controller, request, response, inertia_wrap_render(render), **(named_args || {})) end end end diff --git a/spec/dummy/app/controllers/concerns/meta_taggable.rb b/spec/dummy/app/controllers/concerns/meta_taggable.rb new file mode 100644 index 00000000..6a9c13c2 --- /dev/null +++ b/spec/dummy/app/controllers/concerns/meta_taggable.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module MetaTaggable + extend ActiveSupport::Concern + + included do + before_action :set_meta_tags, only: :override_tags_from_module + end + + def set_meta_tags + inertia_meta + .add({ + name: 'meta_tag_from_concern', + content: 'This should be overriden by the controller', + head_key: 'meta_tag_from_concern', + }) + .add({ + name: 'unnecessary_tag', + content: 'This tag will be removed', + head_key: 'unnecessary_tag', + }) + .add({ + name: 'please_remove_me', + content: 'no head_key to target!', + }) + end +end diff --git a/spec/dummy/app/controllers/inertia_meta_controller.rb b/spec/dummy/app/controllers/inertia_meta_controller.rb new file mode 100644 index 00000000..10e3a808 --- /dev/null +++ b/spec/dummy/app/controllers/inertia_meta_controller.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +class InertiaMetaController < ApplicationController + include MetaTaggable + + before_action :set_description_meta_tag, only: [:from_before_filter, :cleared_meta] + before_action :set_deduplicatable_tags, only: :auto_dedup + + inertia_config( + default_render: -> { action_name == :meta_with_default_render } + ) + + def basic + render inertia: 'TestComponent', meta: [ + { name: 'description', content: 'Inertia rules', head_key: 'first_head_key' }, + { tag_name: 'title', inner_content: 'The Inertia title', head_key: 'second_head_key' }, + { http_equiv: 'content-security-policy', content: "default-src 'self';", head_key: 'third_head_key' } + ] + end + + def multiple_title_tags + render inertia: 'TestComponent', meta: [ + { tag_name: 'title', inner_content: 'The Inertia title', head_key: 'first_head_key' }, + { title: 'The second Inertia title', head_key: 'second_head_key' } + ] + end + + def from_before_filter + render inertia: 'TestComponent' + end + + def with_duplicate_head_keys + render inertia: 'TestComponent', meta: [ + { name: 'description', content: 'This is a description', head_key: 'duplicate_key' }, + { name: 'description2', content: 'This is another description', head_key: 'duplicate_key' } + ] + end + + def override_tags_from_module + inertia_meta.add({ + name: 'meta_tag_from_concern', + content: 'This is overriden by the controller', + head_key: 'meta_tag_from_concern', + }) + + inertia_meta.remove('unnecessary_tag') + + inertia_meta.remove do |tag| + tag[:name] == 'please_remove_me' + end + + render inertia: 'TestComponent' + end + + def auto_dedup + render inertia: 'TestComponent', meta: [ + { + 'name' => 'description', + 'content' => 'Overridden description', + }, + { + 'property' => 'og:description', + 'content' => 'Overridden Open Graph description', + }, + { + 'http_equiv' => 'content-security-policy', + 'content' => 'Overridden CSP', + }, + { + 'charset' => 'Overridden charset', + } + ] + end + + def allowed_duplicates + render inertia: 'TestComponent', meta: [ + { property: 'article:author', content: 'Cassian Andor', allow_duplicates: true }, + { property: 'article:author', content: 'Tony Gilroy', allow_duplicates: true } + ] + end + + def cleared_meta + inertia_meta.clear + render inertia: 'TestComponent' + end + + def meta_with_default_render + render inertia: { some: 'prop' }, meta: [ + { name: 'description', content: 'default rendering still works' } + ] + end + + protected + + def set_description_meta_tag + inertia_meta.add({ + name: 'description', + content: 'This is a description set from a before filter', + head_key: 'before_filter_tag', + }) + end + + def set_deduplicatable_tags + inertia_meta.add([ + { + 'name' => 'description', + 'content' => 'Default description', + }, + { + 'property' => 'og:description', + 'content' => 'Default Open Graph description', + }, + { + 'http_equiv' => 'content-security-policy', + 'content' => 'Default CSP', + }, + { + 'charset' => 'Default charset', + } + ]) + end +end diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index 6b0597ac..717fbaf8 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -88,4 +88,14 @@ get 'encrypt_history_override_config' => 'inertia_encrypt_history#override_config' get 'encrypt_history_clear_history' => 'inertia_encrypt_history#clear_history' post 'encrypt_history_clear_history_after_redirect' => 'inertia_encrypt_history#clear_history_after_redirect' + + get 'basic_meta' => 'inertia_meta#basic' + get 'multiple_title_tags_meta' => 'inertia_meta#multiple_title_tags' + get 'from_before_filter_meta' => 'inertia_meta#from_before_filter' + get 'with_duplicate_head_keys_meta' => 'inertia_meta#with_duplicate_head_keys' + get 'override_tags_from_module_meta' => 'inertia_meta#override_tags_from_module' + get 'auto_dedup_meta' => 'inertia_meta#auto_dedup' + get 'allowed_duplicates_meta' => 'inertia_meta#allowed_duplicates' + get 'cleared_meta' => 'inertia_meta#cleared_meta' + get 'meta_with_default_render' => 'inertia_meta#meta_with_default_render' end diff --git a/spec/inertia/helper_spec.rb b/spec/inertia/helper_spec.rb index 92f0277b..5450c19d 100644 --- a/spec/inertia/helper_spec.rb +++ b/spec/inertia/helper_spec.rb @@ -1,24 +1,10 @@ # frozen_string_literal: true -RSpec.describe InertiaRails::Helper do - let(:controller) { ApplicationController.new } - - let(:test_helper) do - Class.new do - include InertiaRails::Helper - - attr_accessor :controller - end.new - end - - before do - test_helper.controller = controller - end - +RSpec.describe InertiaRails::Helper, type: :helper do describe '#inertia_rendering?' do context 'when not rendering through Inertia' do it 'returns nil' do - expect(test_helper.inertia_rendering?).to be_nil + expect(helper.inertia_rendering?).to be_nil end end @@ -28,7 +14,45 @@ end it 'returns true' do - expect(test_helper.inertia_rendering?).to be true + expect(helper.inertia_rendering?).to be true + end + end + end + + describe '#inertia_meta_tags' do + context 'basic rendering' do + before do + controller.instance_variable_set(:@_inertia_page, { + props: { + _inertia_meta: [ + InertiaRails::MetaTag.new(name: 'description', content: 'Inertia rules', head_key: 'my_key') + ], + }, + }) + end + + it 'generates a meta tag' do + expect(helper.inertia_meta_tags).to eq('<meta name="description" content="Inertia rules" inertia="my_key">') + end + end + + context 'with multiple meta tags' do + before do + controller.instance_variable_set(:@_inertia_page, { + props: { + _inertia_meta: [ + InertiaRails::MetaTag.new(tag_name: 'title', inner_content: 'Inertia Page Title', head_key: 'meta-12345678'), + InertiaRails::MetaTag.new(name: 'description', content: 'Inertia rules', head_key: 'meta-23456789'), + InertiaRails::MetaTag.new(tag_name: 'script', type: 'application/ld+json', inner_content: { '@context': 'https://schema.org' }, head_key: 'meta-34567890') + ], + }, + }) + end + + it 'generates multiple meta tags' do + expect(helper.inertia_meta_tags).to include("<title inertia=\"title\">Inertia Page Title\n") + expect(helper.inertia_meta_tags).to include("\n") + expect(helper.inertia_meta_tags).to include('') end end end diff --git a/spec/inertia/meta_tag_rendering_spec.rb b/spec/inertia/meta_tag_rendering_spec.rb new file mode 100644 index 00000000..af03cbfc --- /dev/null +++ b/spec/inertia/meta_tag_rendering_spec.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +RSpec.describe 'rendering inertia meta tags', type: :request do + let(:headers) do + { + 'X-Inertia' => true, + 'X-Inertia-Partial-Component' => 'TestComponent', + } + end + + it 'returns meta tag data' do + get basic_meta_path, headers: headers + + expect(response.parsed_body['props']['_inertia_meta']).to match_array( + [ + { + 'tagName' => 'meta', + 'name' => 'description', + 'content' => 'Inertia rules', + 'headKey' => 'first_head_key', + }, + { + 'tagName' => 'title', + 'innerContent' => 'The Inertia title', + 'headKey' => 'title', + }, + { + 'tagName' => 'meta', + 'httpEquiv' => 'content-security-policy', + 'content' => "default-src 'self';", + 'headKey' => 'third_head_key', + } + ] + ) + end + + context 'with multiple title tags' do + it 'only renders the last title tag' do + get multiple_title_tags_meta_path, headers: headers + + expect(response.parsed_body['props']['_inertia_meta']).to eq( + [ + { + 'tagName' => 'title', + 'innerContent' => 'The second Inertia title', + 'headKey' => 'title', + } + ] + ) + end + end + + context 'with a before filter setting meta tags' do + it 'returns the meta tag set from the before filter' do + get from_before_filter_meta_path, headers: headers + + expect(response.parsed_body['props']['_inertia_meta']).to eq( + [ + { + 'tagName' => 'meta', + 'name' => 'description', + 'content' => 'This is a description set from a before filter', + 'headKey' => 'before_filter_tag', + } + ] + ) + end + end + + context 'with duplicate head keys' do + it 'returns the last meta tag with the same head key' do + get with_duplicate_head_keys_meta_path, headers: headers + + expect(response.parsed_body['props']['_inertia_meta']).to eq( + [ + { + 'tagName' => 'meta', + 'name' => 'description2', # Contrived mismatch between meta tag names to ensure head_key deduplication works + 'content' => 'This is another description', + 'headKey' => 'duplicate_key', + } + ] + ) + end + end + + context 'with meta tags set from a module' do + it 'overrides the meta tag set from the module' do + get override_tags_from_module_meta_path, headers: headers + + expect(response.parsed_body['props']['_inertia_meta']).to eq([ + { + 'tagName' => 'meta', + 'name' => 'meta_tag_from_concern', + 'content' => 'This is overriden by the controller', + 'headKey' => 'meta_tag_from_concern', + } + ]) + end + end + + describe 'automatic deduplication without head_keys' do + # Don't care what the auto generated head keys are, just check the content + let(:meta_without_head_keys) do + response.parsed_body['props']['_inertia_meta'].map do |tag| + tag.reject { |properties| properties['headKey'] } + end + end + + it 'dedups on :name, :property, :http_equiv, :charset, and :itemprop keys' do + get auto_dedup_meta_path, headers: headers + + expect(meta_without_head_keys).to match_array([ + { + 'tagName' => 'meta', + 'name' => 'description', + 'content' => 'Overridden description', + }, + { + 'tagName' => 'meta', + 'property' => 'og:description', + 'content' => 'Overridden Open Graph description', + }, + { + 'tagName' => 'meta', + 'httpEquiv' => 'content-security-policy', + 'content' => 'Overridden CSP', + }, + { + 'tagName' => 'meta', + 'charset' => 'Overridden charset', + } + ]) + end + + it 'allows duplicates for specified meta tags' do + get allowed_duplicates_meta_path, headers: headers + + expect(meta_without_head_keys).to match_array([ + { + 'tagName' => 'meta', + 'property' => 'article:author', + 'content' => 'Cassian Andor', + }, + { + 'tagName' => 'meta', + 'property' => 'article:author', + 'content' => 'Tony Gilroy', + } + ]) + end + end + + it 'can clear meta tags' do + get cleared_meta_path, headers: headers + expect(response.parsed_body['props']['_inertia_meta']).not_to be + end + + context 'with default rendering' do + it 'returns meta tags with default rendering' do + get meta_with_default_render_path, headers: headers + + expect(response.parsed_body['props']['some']).to eq('prop') + expect(response.parsed_body['props']['_inertia_meta']).to eq( + [ + { + 'tagName' => 'meta', + 'name' => 'description', + 'content' => 'default rendering still works', + 'headKey' => 'meta-name-description', + } + ] + ) + end + end +end diff --git a/spec/inertia/meta_tag_spec.rb b/spec/inertia/meta_tag_spec.rb new file mode 100644 index 00000000..96b97eda --- /dev/null +++ b/spec/inertia/meta_tag_spec.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +RSpec.describe InertiaRails::MetaTag do + let(:meta_tag) { described_class.new(head_key: dummy_head_key, name: 'description', content: 'Inertia rules') } + let(:dummy_head_key) { 'meta-12345678' } + let(:tag_helper) { ActionController::Base.helpers.tag } + + describe '#to_json' do + it 'returns the meta tag as JSON' do + expected_json = { + tagName: :meta, + headKey: dummy_head_key, + name: 'description', + content: 'Inertia rules', + }.to_json + + expect(meta_tag.to_json).to eq(expected_json) + end + + it 'transforms snake_case keys to camelCase' do + meta_tag = described_class.new(head_key: dummy_head_key, http_equiv: 'content-security-policy', content: "default-src 'self'") + + expected_json = { + tagName: :meta, + headKey: dummy_head_key, + httpEquiv: 'content-security-policy', + content: "default-src 'self'", + }.to_json + + expect(meta_tag.to_json).to eq(expected_json) + end + + it 'handles JSON LD content' do + meta_tag = described_class.new(tag_name: 'script', head_key: dummy_head_key, type: 'application/ld+json', inner_content: { '@context': 'https://schema.org' }) + + expected_json = { + tagName: :script, + headKey: dummy_head_key, + type: 'application/ld+json', + innerContent: { '@context': 'https://schema.org' }, + }.to_json + + expect(meta_tag.to_json).to eq(expected_json) + end + + it 'marks executable script tags with text/plain' do + meta_tag = described_class.new(tag_name: 'script', head_key: dummy_head_key, inner_content: '', type: 'application/javascript') + + expected_json = { + tagName: :script, + headKey: dummy_head_key, + type: 'text/plain', + innerContent: '', + }.to_json + + expect(meta_tag.to_json).to eq(expected_json) + end + end + + describe 'generated head keys' do + it 'generates a headKey of the format {tag name}-{hexdigest of tag content}' do + meta_tag = described_class.new(some_name: 'description', content: 'Inertia rules') + expected_head_key = "meta-#{Digest::MD5.hexdigest('content=Inertia rules&some_name=description')[0, 8]}" + + expect(meta_tag.as_json[:headKey]).to eq(expected_head_key) + end + + it 'generates the same headKey regardless of hash data order' do + first_tags = described_class.new(some_name: 'description', content: 'Inertia rules').as_json + first_head_key = first_tags[:headKey] + + second_tags = described_class.new(content: 'Inertia rules', some_name: 'description').as_json + second_head_key = second_tags[:headKey] + + expect(first_head_key).to eq(second_head_key) + end + + it 'generates a different headKey for different content' do + first_tags = described_class.new(some_name: 'thing', content: 'Inertia rules').as_json + first_head_key = first_tags[:headKey] + + second_tags = described_class.new(some_name: 'thing', content: 'Inertia rocks').as_json + second_head_key = second_tags[:headKey] + + expect(first_head_key).not_to eq(second_head_key) + end + + it 'respects a user specified head_key' do + custom_head_key = 'blah' + meta_tag = described_class.new(head_key: custom_head_key, name: 'description', content: 'Inertia rules') + + expect(meta_tag.as_json[:headKey]).to eq(custom_head_key) + end + + it 'generates a head key by the name attribute if no head_key is provided' do + meta_tag = described_class.new(name: 'description', content: 'Inertia rules') + + expect(meta_tag.as_json[:headKey]).to eq('meta-name-description') + end + + it 'generates a head key by the http_equiv attribute if no head_key is provided' do + meta_tag = described_class.new(http_equiv: 'content-security-policy', content: "default-src 'self'") + + expect(meta_tag.as_json[:headKey]).to eq('meta-http_equiv-content-security-policy') + end + + it 'generates a head key by the property attribute if no head_key is provided' do + meta_tag = described_class.new(property: 'og:title', content: 'Inertia Rocks') + + expect(meta_tag.as_json[:headKey]).to eq('meta-property-og-title') + end + + context 'with allow_duplicates set to true' do + it 'generates a head key with a unique suffix' do + meta_tag = described_class.new(name: 'description', content: 'Inertia rules', allow_duplicates: true) + + expect(meta_tag.as_json[:headKey]).to eq("meta-name-description-#{Digest::MD5.hexdigest('content=Inertia rules&name=description')[0, 8]}") + end + end + end + + describe '#to_tag' do + it 'returns a string meta tag' do + tag = meta_tag.to_tag(tag_helper) + expect(tag).to be_a(String) + expect(tag).to eq('') + end + + it 'renders kebab case' do + meta_tag = described_class.new(tag_name: :meta, head_key: dummy_head_key, http_equiv: 'X-UA-Compatible', content: 'IE=edge') + + tag = meta_tag.to_tag(tag_helper) + + expect(tag).to eq('') + end + + describe 'script tag rendering' do + it 'renders JSON LD content correctly' do + meta_tag = described_class.new(tag_name: :script, head_key: dummy_head_key, type: 'application/ld+json', inner_content: { '@context': 'https://schema.org' }) + + tag = meta_tag.to_tag(tag_helper) + + expect(tag).to eq('') + end + + it 'adds text/plain and escapes all other script tags' do + meta_tag = described_class.new(tag_name: :script, head_key: dummy_head_key, type: 'application/javascript', inner_content: 'alert("XSS")') + + tag = meta_tag.to_tag(tag_helper) + + expect(tag).to eq('') + end + end + + describe 'rendering unary tags' do + described_class::UNARY_TAGS.each do |tag_name| + it "renders a content attribute for a #{tag_name} tag" do + meta_tag = described_class.new(tag_name: tag_name, head_key: dummy_head_key, content: 'Inertia rules') + + tag = meta_tag.to_tag(tag_helper) + + expect(tag).to include("<#{tag_name} content=\"Inertia rules\" inertia=\"meta-12345678\">") + end + end + end + + it 'escapes inner content for non-script tags' do + meta_tag = described_class.new(tag_name: :div, head_key: dummy_head_key, inner_content: '') + + tag = meta_tag.to_tag(tag_helper) + + expect(tag).to eq('
<script>alert("XSS")</script>
') + end + end + + describe 'title tag rendering' do + it 'renders a title tag if only a title key is provided' do + meta_tag = described_class.new(tag_name: :title, head_key: dummy_head_key, inner_content: 'Inertia Page Title') + + tag = meta_tag.to_tag(ActionController::Base.helpers.tag) + + expect(tag).to eq('Inertia Page Title') + end + + context 'when only a title key is provided' do + let(:title_tag) { described_class.new(title: 'Inertia Is Great', head_key: 'title') } + + it 'renders JSON correctly' do + expect(title_tag.to_json).to eq({ + tagName: :title, + headKey: 'title', + innerContent: 'Inertia Is Great', + }.to_json) + end + + it 'renders a title tag' do + expect(title_tag.to_tag(tag_helper)).to eq('Inertia Is Great') + end + end + end +end