page_structured_data 1.0.13 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e263a2b3ad541c2b5e72aa79e71d47d72803ddb8a4ac286898e6c6f35105394
4
- data.tar.gz: 96f01f8fc820456eb69ba5a351f8971022758088d0a5633240c38c7831df43f9
3
+ metadata.gz: f455392a943824c8c84fbeb1a95e0250d9f959f98c8e5361c5d57d1f38140d91
4
+ data.tar.gz: 306f4a1246e196db8a7e58bb89aa1a73454c3fc873c3431818a1c0b2cdb5bc93
5
5
  SHA512:
6
- metadata.gz: 802b70166a18b5475e702822cfb7b0c565c7fc59d8dd5059831261676831e1150c95b8f0e1e11b0de493aa120f3e23f2ddd9a619238ebcdd5f25c8f3920d3d6e
7
- data.tar.gz: bc818be71ea5d603ac3fb6b84e99dcf79fd76607f3272039cde49d65c772f365d812c0f7d260cdf7beda88762012994ae207b309e7a5dc21b94ab129608e0c9e
6
+ metadata.gz: 6a0261a5b6a280806f36fccea7676b6f0656e0fab4f0ab3f13087984923f904b0895a88921527783bdbecaa30c3f653ee16ee8e1fd4dfdc88a294047203fa093
7
+ data.tar.gz: f8cc5db7758b6170705eb55fa53208d1b38f5cf69343e734c6e62ded077916cc7d74ff7d8a54cbff7ecbed287cbe2f8fc5aaab77c0f425abcf97610837075872
data/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@ All notable changes to this project are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 1.0.15 - 2026-08-01
8
+
9
+ - Add optional page-level `social_description` support for distinct Open Graph
10
+ and X/Twitter descriptions with fallback to the standard SEO description.
11
+ - Escape JSON-LD independently of host-app Active Support configuration before
12
+ rendering script tags as HTML-safe.
13
+ - Stop rendering invalid current-page-only breadcrumb JSON-LD while preserving
14
+ explicit breadcrumb hierarchies.
15
+ - Render Google-compatible `text` for discussion forum posts while retaining
16
+ `articleBody` for backward compatibility, and warn when author or post
17
+ content is missing.
18
+ - Ensure the documented `rake test` command executes the complete Rails test
19
+ suite and constrain test dependencies to compatible Minitest and Rails 7.0
20
+ SQLite lines.
21
+
22
+ ## 1.0.14 - 2026-07-05
23
+
24
+ - Add page-level `robots` meta tag support.
25
+ - Add richer optional schema fields for article-like page types.
26
+ - Add README common pattern examples.
27
+ - Add focused rendered metadata output coverage.
28
+ - Add soft validation helpers with `warnings` and `valid?`.
29
+ - Improve README and gem metadata positioning.
30
+
7
31
  ## 1.0.13 - 2026-05-06
8
32
 
9
33
  - Add `PageStructuredData::PageTypes::Person` for reusable schema.org person values.
data/README.md CHANGED
@@ -3,7 +3,9 @@
3
3
  [![Gem Version](https://badge.fury.io/rb/page_structured_data.svg)](https://rubygems.org/gems/page_structured_data)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](MIT-LICENSE)
5
5
 
6
- PageStructuredData is a small Rails engine for rendering page-level SEO and social sharing metadata from one page object.
6
+ PageStructuredData is a small Rails engine that keeps SEO metadata, social sharing tags, and JSON-LD structured data in one page object and one view partial.
7
+
8
+ Use it when a Rails app has metadata spread across layouts, helpers, presenters, and page-specific templates. PageStructuredData gives each page one explicit metadata object, then renders the `<title>`, meta tags, Open Graph tags, Twitter Card tags, breadcrumbs, and schema.org JSON-LD consistently.
7
9
 
8
10
  It helps Rails applications render:
9
11
 
@@ -49,15 +51,12 @@ Configure application-wide defaults in an initializer:
49
51
  Rails.application.config.after_initialize do
50
52
  PageStructuredData.config do |config|
51
53
  config.base_app_name = "AwesomestApp"
52
- config.render_default_breadcrumb_json_ld = true
53
54
  end
54
55
  end
55
56
  ```
56
57
 
57
58
  `base_app_name` is appended to generated page titles.
58
59
 
59
- `render_default_breadcrumb_json_ld` controls whether pages without an explicit breadcrumb render current-page-only breadcrumb JSON-LD. It defaults to `true` for backward compatibility. Set it to `false` if you only want breadcrumb JSON-LD when a `PageStructuredData::Breadcrumbs` object is passed to the page.
60
-
61
60
  For example:
62
61
 
63
62
  ```ruby
@@ -96,12 +95,20 @@ Set `@page_meta` in the controller or view before the layout renders:
96
95
  title: "Home",
97
96
  extra_title: "Official Page",
98
97
  description: "Welcome to my page",
98
+ social_description: "See what is new on my page",
99
99
  image: image_url("social/home.png"),
100
100
  canonical_url: home_url,
101
- fallback_image: image_url("social/default.png")
101
+ fallback_image: image_url("social/default.png"),
102
+ robots: "index,follow"
102
103
  )
103
104
  ```
104
105
 
106
+ `description` renders the standard SEO description. `social_description` is
107
+ optional and renders the Open Graph and X/Twitter descriptions when it is
108
+ nonblank. When `social_description` is nil or blank, those social tags fall
109
+ back to `description`. This allows intentional social copy without changing the
110
+ standard SEO description.
111
+
105
112
  The generated title is built from:
106
113
 
107
114
  1. `title`
@@ -136,7 +143,11 @@ Pass the breadcrumbs into the page object:
136
143
 
137
144
  This renders `BreadcrumbList` JSON-LD similar to Google's breadcrumb structured data format.
138
145
 
139
- Current compatibility note: when no breadcrumb object is passed, `PageStructuredData::Page` renders current-page-only breadcrumb JSON-LD by default. To opt out, set `config.render_default_breadcrumb_json_ld = false`.
146
+ Breadcrumb JSON-LD renders only when the resulting list contains at least two
147
+ items. Pages without a breadcrumb hierarchy do not emit a current-page-only
148
+ list because Google does not accept one-item breadcrumb data. The existing
149
+ `render_default_breadcrumb_json_ld` configuration remains accepted for
150
+ compatibility, but it cannot make an invalid one-item list render.
140
151
 
141
152
  ## Structured Page Types
142
153
 
@@ -265,6 +276,177 @@ website_page_type = PageStructuredData::PageTypes::WebSite.new(
265
276
  )
266
277
  ```
267
278
 
279
+ ## Common Patterns
280
+
281
+ ### Article Page
282
+
283
+ Use `BlogPosting` or `NewsArticle` when a page represents editorial content:
284
+
285
+ ```ruby
286
+ publisher = PageStructuredData::PageTypes::Organization.new(
287
+ name: "Example",
288
+ url: root_url
289
+ )
290
+
291
+ author = PageStructuredData::PageTypes::Person.new(
292
+ name: @article.author.name,
293
+ url: author_url(@article.author)
294
+ )
295
+
296
+ article_page_type = PageStructuredData::PageTypes::BlogPosting.new(
297
+ headline: @article.title,
298
+ description: @article.summary,
299
+ article_body: @article.body.to_plain_text,
300
+ url: article_url(@article),
301
+ main_entity_of_page: article_url(@article),
302
+ publisher: publisher,
303
+ article_section: @article.category.name,
304
+ keywords: @article.tags.pluck(:name),
305
+ word_count: @article.word_count,
306
+ in_language: "en",
307
+ published_at: @article.published_at,
308
+ updated_at: @article.updated_at,
309
+ authors: [author],
310
+ image: url_for(@article.cover_image),
311
+ likes_count: @article.likes_count
312
+ )
313
+
314
+ @page_meta = PageStructuredData::Page.new(
315
+ title: @article.title,
316
+ description: @article.summary,
317
+ image: url_for(@article.cover_image),
318
+ canonical_url: article_url(@article),
319
+ breadcrumb: article_breadcrumbs,
320
+ page_type: article_page_type
321
+ )
322
+ ```
323
+
324
+ ### Homepage
325
+
326
+ Use both `Organization` and `WebSite` when the page represents the public home of a site or organization:
327
+
328
+ ```ruby
329
+ founder = PageStructuredData::PageTypes::Person.new(
330
+ name: "Jane Doe",
331
+ url: "https://example.com/jane"
332
+ )
333
+
334
+ organization = PageStructuredData::PageTypes::Organization.new(
335
+ name: "Example",
336
+ url: root_url,
337
+ description: "Useful software from Example",
338
+ logo: image_url("logo.png"),
339
+ same_as: ["https://github.com/example"],
340
+ founder: founder
341
+ )
342
+
343
+ website = PageStructuredData::PageTypes::WebSite.new(
344
+ name: "Example",
345
+ url: root_url,
346
+ description: "Useful software from Example",
347
+ publisher: organization
348
+ )
349
+
350
+ @page_meta = PageStructuredData::Page.new(
351
+ title: "Example",
352
+ description: "Useful software from Example",
353
+ image: image_url("social/home.png"),
354
+ canonical_url: root_url,
355
+ page_types: [organization, website],
356
+ render_breadcrumb_json_ld: false
357
+ )
358
+ ```
359
+
360
+ ### Forum Or Community Post
361
+
362
+ Use `DiscussionForumPosting` for public, user-authored, timestamped posts:
363
+
364
+ ```ruby
365
+ post_author = PageStructuredData::PageTypes::Person.new(
366
+ name: @post.user.name,
367
+ url: user_url(@post.user)
368
+ )
369
+
370
+ post_page_type = PageStructuredData::PageTypes::DiscussionForumPosting.new(
371
+ headline: @post.title,
372
+ text: @post.content_plaintext,
373
+ url: post_url(@post),
374
+ published_at: @post.created_at,
375
+ updated_at: @post.updated_at,
376
+ authors: [post_author],
377
+ comments_count: @post.comments_count
378
+ )
379
+
380
+ @page_meta = PageStructuredData::Page.new(
381
+ title: @post.title,
382
+ description: @post.excerpt,
383
+ canonical_url: post_url(@post),
384
+ page_type: post_page_type
385
+ )
386
+ ```
387
+
388
+ Only pass engagement counts that are public and visible on the rendered page.
389
+ `DiscussionForumPosting` renders the supplied `text` as both `text` and the
390
+ backward-compatible `articleBody` property.
391
+
392
+ ### Page-Local Site Name
393
+
394
+ Override or suppress the global app name for one page:
395
+
396
+ ```ruby
397
+ PageStructuredData.base_app_name = "Example"
398
+
399
+ PageStructuredData::Page.new(
400
+ title: "Docs",
401
+ base_app_name: "Developer Docs"
402
+ ).page_title
403
+ # => "Docs - Developer Docs"
404
+
405
+ PageStructuredData::Page.new(
406
+ title: "Minimal",
407
+ base_app_name: ""
408
+ ).page_title
409
+ # => "Minimal"
410
+ ```
411
+
412
+ ### Breadcrumb JSON-LD Control
413
+
414
+ Suppress an explicit breadcrumb hierarchy for one page:
415
+
416
+ ```ruby
417
+ PageStructuredData::Page.new(
418
+ title: "Landing Page",
419
+ render_breadcrumb_json_ld: false
420
+ )
421
+ ```
422
+
423
+ Pages need at least one hierarchy item plus the current page before breadcrumb
424
+ JSON-LD renders. Setting `render_breadcrumb_json_ld: true` does not synthesize
425
+ missing hierarchy data.
426
+
427
+ ### Paginated Archive
428
+
429
+ Use `robots` for archive, search, filtered, or paginated pages that should be crawlable but not indexed:
430
+
431
+ ```ruby
432
+ @page_meta = PageStructuredData::Page.new(
433
+ title: "Articles",
434
+ extra_title: "Page #{@pagy.page}",
435
+ description: "Browse articles from Example",
436
+ canonical_url: articles_url(page: @pagy.page),
437
+ robots: "noindex,follow"
438
+ )
439
+ ```
440
+
441
+ `robots` can also be passed as an array:
442
+
443
+ ```ruby
444
+ PageStructuredData::Page.new(
445
+ title: "Articles",
446
+ robots: ["noindex", "follow"]
447
+ )
448
+ ```
449
+
268
450
  ## API Reference
269
451
 
270
452
  ### `PageStructuredData::Page`
@@ -273,6 +455,7 @@ website_page_type = PageStructuredData::PageTypes::WebSite.new(
273
455
  PageStructuredData::Page.new(
274
456
  title:,
275
457
  description: nil,
458
+ social_description: nil,
276
459
  image: nil,
277
460
  extra_title: "",
278
461
  breadcrumb: nil,
@@ -281,18 +464,46 @@ PageStructuredData::Page.new(
281
464
  canonical_url: nil,
282
465
  fallback_image: nil,
283
466
  base_app_name: nil,
284
- render_breadcrumb_json_ld: nil
467
+ render_breadcrumb_json_ld: nil,
468
+ robots: nil
285
469
  )
286
470
  ```
287
471
 
472
+ `social_description` overrides `description` for Open Graph and X/Twitter
473
+ description tags when nonblank. It does not change the standard SEO description
474
+ tag.
288
475
  `base_app_name` overrides `PageStructuredData.base_app_name` for one page. Pass an empty string to suppress the global app name for a specific page.
289
- `render_breadcrumb_json_ld` can be set to `true` or `false` for one page. Leave it as `nil` to use the global `PageStructuredData.render_default_breadcrumb_json_ld` behavior for generated default breadcrumbs. Explicit breadcrumb objects still render when the global default is disabled unless the page sets `render_breadcrumb_json_ld: false`.
476
+ `render_breadcrumb_json_ld` can be set to `false` to suppress an explicit breadcrumb object. A true value never renders a breadcrumb list with fewer than two items.
477
+ `robots` can be a string or array and renders a `<meta name="robots">` tag when present.
290
478
 
291
479
  Important methods:
292
480
 
293
481
  - `page_title`: returns the composed page title.
294
482
  - `json_lds`: returns the JSON-LD script tags for breadcrumbs and page type data.
295
483
  - `resolved_image`: returns `image` or `fallback_image`.
484
+ - `resolved_social_description`: returns a nonblank `social_description` or falls back to `description`.
485
+ - `robots_content`: returns the rendered robots directives.
486
+ - `warnings`: returns soft validation warnings for the page and page types.
487
+ - `valid?`: returns `true` when `warnings` is empty.
488
+
489
+ ### Soft Validation
490
+
491
+ Page objects and page type objects expose non-blocking validation helpers:
492
+
493
+ ```ruby
494
+ page_type = PageStructuredData::PageTypes::Organization.new(
495
+ name: nil,
496
+ url: nil
497
+ )
498
+
499
+ page_type.warnings
500
+ # => ["name is required", "url is required"]
501
+
502
+ page_type.valid?
503
+ # => false
504
+ ```
505
+
506
+ `warnings` does not stop rendering. It is intended for tests, previews, and development checks where you want to catch incomplete structured data before publishing a page.
296
507
 
297
508
  ### `PageStructuredData::Breadcrumbs`
298
509
 
@@ -320,9 +531,16 @@ PageStructuredData::PageTypes::BlogPosting.new(
320
531
  images: [],
321
532
  authors: [],
322
533
  image: nil,
534
+ description: nil,
323
535
  article_body: nil,
324
536
  text: nil,
325
537
  url: nil,
538
+ main_entity_of_page: nil,
539
+ publisher: nil,
540
+ article_section: nil,
541
+ keywords: nil,
542
+ word_count: nil,
543
+ in_language: nil,
326
544
  interaction_statistics: [],
327
545
  likes_count: nil,
328
546
  comments_count: nil,
@@ -338,9 +556,16 @@ PageStructuredData::PageTypes::NewsArticle.new(
338
556
  images: [],
339
557
  authors: [],
340
558
  image: nil,
559
+ description: nil,
341
560
  article_body: nil,
342
561
  text: nil,
343
562
  url: nil,
563
+ main_entity_of_page: nil,
564
+ publisher: nil,
565
+ article_section: nil,
566
+ keywords: nil,
567
+ word_count: nil,
568
+ in_language: nil,
344
569
  interaction_statistics: [],
345
570
  likes_count: nil,
346
571
  comments_count: nil,
@@ -351,6 +576,9 @@ PageStructuredData::PageTypes::NewsArticle.new(
351
576
  `authors` can be an array of hashes, `PageStructuredData::PageTypes::Person` objects, or other objects that respond to `to_h`.
352
577
  `image` is a convenience option for one image URL. Use `images` when passing multiple image URLs.
353
578
  `text` is an alias for `article_body`.
579
+ `main_entity_of_page` can be a URL, hash, or object that responds to `to_h`.
580
+ `publisher` can be a hash or another page type that responds to `to_h`, such as `PageStructuredData::PageTypes::Organization`.
581
+ `keywords` can be a string or an array.
354
582
  `interaction_statistics` should be an array of `PageStructuredData::PageTypes::InteractionStatistic` objects or schema-compatible hashes.
355
583
 
356
584
  Important methods:
@@ -366,9 +594,16 @@ PageStructuredData::PageTypes::DiscussionForumPosting.new(
366
594
  images: [],
367
595
  authors: [],
368
596
  image: nil,
597
+ description: nil,
369
598
  article_body: nil,
370
599
  text: nil,
371
600
  url: nil,
601
+ main_entity_of_page: nil,
602
+ publisher: nil,
603
+ article_section: nil,
604
+ keywords: nil,
605
+ word_count: nil,
606
+ in_language: nil,
372
607
  interaction_statistics: [],
373
608
  likes_count: nil,
374
609
  comments_count: nil,
data/Rakefile CHANGED
@@ -6,3 +6,8 @@ load "rails/tasks/engine.rake"
6
6
  load "rails/tasks/statistics.rake"
7
7
 
8
8
  require "bundler/gem_tasks"
9
+
10
+ desc "Run the complete test suite"
11
+ task :test do
12
+ ruby File.expand_path("bin/rails", __dir__), "test"
13
+ end
@@ -22,11 +22,10 @@ module PageStructuredData
22
22
  end
23
23
 
24
24
  def json_ld(current_page_title:)
25
- %(
26
- <script type="application/ld+json">
27
- #{to_h(current_page_title: current_page_title).to_json}
28
- </script>
29
- )
25
+ node = to_h(current_page_title: current_page_title)
26
+ return if node[:itemListElement].size < 2
27
+
28
+ JsonLd.script_tag(node)
30
29
  end
31
30
 
32
31
  private
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/string/output_safety'
4
+
5
+ module PageStructuredData
6
+ # Builds JSON-LD script tags with escaping that does not depend on host-app
7
+ # Active Support configuration.
8
+ module JsonLd
9
+ module_function
10
+
11
+ def script_tag(node)
12
+ json = ActiveSupport::JSON.encode(node)
13
+ escaped_json = ERB::Util.json_escape(json)
14
+
15
+ %(
16
+ <script type="application/ld+json">
17
+ #{escaped_json}
18
+ </script>
19
+ )
20
+ end
21
+ end
22
+ end
@@ -3,14 +3,15 @@
3
3
  module PageStructuredData
4
4
  # Basic page metadata for any page
5
5
  class Page
6
- attr_reader :title, :description, :image, :extra_title, :breadcrumb, :page_type, :page_types, :canonical_url,
7
- :fallback_image, :base_app_name, :render_breadcrumb_json_ld
6
+ attr_reader :title, :description, :social_description, :image, :extra_title, :breadcrumb, :page_type, :page_types,
7
+ :canonical_url, :fallback_image, :base_app_name, :render_breadcrumb_json_ld, :robots
8
8
 
9
- def initialize(title:, description: nil, image: nil, # rubocop:disable Metrics/ParameterLists
9
+ def initialize(title:, description: nil, social_description: nil, image: nil, # rubocop:disable Metrics/ParameterLists
10
10
  extra_title: '', breadcrumb: nil, page_type: nil, page_types: nil, canonical_url: nil,
11
- fallback_image: nil, base_app_name: nil, render_breadcrumb_json_ld: nil)
11
+ fallback_image: nil, base_app_name: nil, render_breadcrumb_json_ld: nil, robots: nil)
12
12
  @title = title
13
13
  @description = description
14
+ @social_description = social_description
14
15
  @image = image
15
16
  @extra_title = extra_title
16
17
  @breadcrumb = breadcrumb
@@ -20,6 +21,7 @@ module PageStructuredData
20
21
  @fallback_image = fallback_image
21
22
  @base_app_name = base_app_name
22
23
  @render_breadcrumb_json_ld = render_breadcrumb_json_ld
24
+ @robots = robots
23
25
  end
24
26
 
25
27
  def title_with_hierarchies
@@ -47,12 +49,40 @@ module PageStructuredData
47
49
  image || fallback_image
48
50
  end
49
51
 
52
+ def resolved_social_description
53
+ social_description.presence || description
54
+ end
55
+
56
+ def robots_content
57
+ Array(robots).compact.join(',')
58
+ end
59
+
60
+ def warnings
61
+ page_warnings + page_type_warnings
62
+ end
63
+
64
+ def valid?
65
+ warnings.empty?
66
+ end
67
+
50
68
  private
51
69
 
52
70
  def resolved_page_types
53
71
  Array.wrap(page_types.presence || page_type).compact
54
72
  end
55
73
 
74
+ def page_warnings
75
+ title.present? ? [] : ['title is required']
76
+ end
77
+
78
+ def page_type_warnings
79
+ resolved_page_types.each_with_index.flat_map do |resolved_page_type, index|
80
+ next [] unless resolved_page_type.respond_to?(:warnings)
81
+
82
+ resolved_page_type.warnings.map { |warning| "page type #{index + 1}: #{warning}" }
83
+ end
84
+ end
85
+
56
86
  def breadcrumb_json_ld
57
87
  return if render_breadcrumb_json_ld == false
58
88
  return breadcrumb.json_ld(current_page_title: title) if breadcrumb.present?
@@ -6,18 +6,28 @@ module PageStructuredData
6
6
  class Article
7
7
  include SchemaNode
8
8
 
9
- attr_reader :headline, :images, :published_at, :updated_at, :authors, :article_body, :url,
9
+ attr_reader :headline, :images, :published_at, :updated_at, :authors, :description, :article_body, :url,
10
+ :main_entity_of_page, :publisher, :article_section, :keywords, :word_count, :in_language,
10
11
  :interaction_statistics, :likes_count, :comments_count, :shares_count
11
12
 
12
13
  def initialize(headline:, published_at:, updated_at:, images: [], authors: [], image: nil, article_body: nil, text: nil,
13
- url: nil, interaction_statistics: [], likes_count: nil, comments_count: nil, shares_count: nil)
14
+ description: nil, url: nil, main_entity_of_page: nil, publisher: nil, article_section: nil,
15
+ keywords: nil, word_count: nil, in_language: nil, interaction_statistics: [], likes_count: nil,
16
+ comments_count: nil, shares_count: nil)
14
17
  @headline = headline
15
18
  @images = image.present? ? Array(image) : Array(images)
16
19
  @published_at = published_at
17
20
  @updated_at = updated_at
18
21
  @authors = Array(authors)
22
+ @description = description
19
23
  @article_body = article_body || text
20
24
  @url = url
25
+ @main_entity_of_page = main_entity_of_page
26
+ @publisher = publisher
27
+ @article_section = article_section
28
+ @keywords = keywords
29
+ @word_count = word_count
30
+ @in_language = in_language
21
31
  @interaction_statistics = Array(interaction_statistics)
22
32
  @likes_count = likes_count
23
33
  @comments_count = comments_count
@@ -35,19 +45,30 @@ module PageStructuredData
35
45
  author: authors.map { |author| author_to_h(author) },
36
46
  }
37
47
 
48
+ node[:description] = description if description.present?
38
49
  node[:articleBody] = article_body if article_body.present?
39
50
  node[:url] = url if url.present?
51
+ node[:mainEntityOfPage] = object_to_h(main_entity_of_page) if main_entity_of_page.present?
52
+ node[:publisher] = object_to_h(publisher) if publisher.present?
53
+ node[:articleSection] = article_section if article_section.present?
54
+ node[:keywords] = keywords if keywords.present?
55
+ node[:wordCount] = word_count if word_count.present?
56
+ node[:inLanguage] = object_to_h(in_language) if in_language.present?
40
57
  node[:interactionStatistic] = interaction_statistics_to_h if interaction_statistics_to_h.any?
41
58
 
42
59
  node
43
60
  end
44
61
 
45
62
  def json_ld
46
- %(
47
- <script type="application/ld+json">
48
- #{to_h.to_json}
49
- </script>
50
- )
63
+ JsonLd.script_tag(to_h)
64
+ end
65
+
66
+ def warnings
67
+ required_attribute_warnings(
68
+ headline: headline,
69
+ published_at: published_at,
70
+ updated_at: updated_at
71
+ ) + author_warnings + publisher_warnings + interaction_statistic_warnings
51
72
  end
52
73
 
53
74
  private
@@ -68,6 +89,25 @@ module PageStructuredData
68
89
  )
69
90
  end
70
91
 
92
+ def author_warnings
93
+ authors.each_with_index.flat_map do |author, index|
94
+ if author.respond_to?(:warnings)
95
+ author.warnings.map { |warning| "author #{index + 1}: #{warning}" }
96
+ else
97
+ author_hash = object_to_h(author) || {}
98
+ required_attribute_warnings(name: author_hash[:name] || author_hash['name']).map do |warning|
99
+ "author #{index + 1}: #{warning}"
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ def publisher_warnings
106
+ return [] unless publisher.respond_to?(:warnings)
107
+
108
+ publisher.warnings.map { |warning| "publisher: #{warning}" }
109
+ end
110
+
71
111
  def interaction_statistics_to_h
72
112
  @interaction_statistics_to_h ||= all_interaction_statistics.map do |interaction_statistic|
73
113
  if interaction_statistic.respond_to?(:to_h)
@@ -95,6 +135,14 @@ module PageStructuredData
95
135
 
96
136
  InteractionStatistic.new(interaction_type: interaction_type, user_interaction_count: count)
97
137
  end
138
+
139
+ def interaction_statistic_warnings
140
+ all_interaction_statistics.each_with_index.flat_map do |interaction_statistic, index|
141
+ next [] unless interaction_statistic.respond_to?(:warnings)
142
+
143
+ interaction_statistic.warnings.map { |warning| "interaction statistic #{index + 1}: #{warning}" }
144
+ end
145
+ end
98
146
  end
99
147
  end
100
148
  end
@@ -4,11 +4,28 @@ module PageStructuredData
4
4
  module PageTypes
5
5
  # schema.org structured data for discussion forum posts.
6
6
  class DiscussionForumPosting < Article
7
+ def to_h
8
+ super.tap do |node|
9
+ node[:text] = article_body if article_body.present?
10
+ end
11
+ end
12
+
13
+ def warnings
14
+ super + discussion_forum_warnings
15
+ end
16
+
7
17
  private
8
18
 
9
19
  def schema_type
10
20
  'DiscussionForumPosting'
11
21
  end
22
+
23
+ def discussion_forum_warnings
24
+ warnings = []
25
+ warnings << 'author is required' if authors.empty?
26
+ warnings << 'text or image is required' unless article_body.present? || images.any?(&:present?)
27
+ warnings
28
+ end
12
29
  end
13
30
  end
14
31
  end
@@ -44,6 +44,13 @@ module PageStructuredData
44
44
  )
45
45
  end
46
46
 
47
+ def warnings
48
+ required_attribute_warnings(
49
+ interaction_type: interaction_type,
50
+ user_interaction_count: user_interaction_count
51
+ )
52
+ end
53
+
47
54
  private
48
55
 
49
56
  def interaction_type_to_h
@@ -33,15 +33,24 @@ module PageStructuredData
33
33
  end
34
34
 
35
35
  def json_ld
36
- %(
37
- <script type="application/ld+json">
38
- #{to_h.to_json}
39
- </script>
40
- )
36
+ JsonLd.script_tag(to_h)
37
+ end
38
+
39
+ def warnings
40
+ required_attribute_warnings(name: name, url: url) + nested_warnings
41
41
  end
42
42
 
43
43
  private
44
44
 
45
+ def nested_warnings
46
+ [founder, parent_organization].each_with_index.flat_map do |node, index|
47
+ next [] unless node.respond_to?(:warnings)
48
+
49
+ prefix = index.zero? ? 'founder' : 'parent organization'
50
+ node.warnings.map { |warning| "#{prefix}: #{warning}" }
51
+ end
52
+ end
53
+
45
54
  def parent_organization_to_h
46
55
  return object_to_h(parent_organization) if parent_organization.respond_to?(:to_h) && !parent_organization.is_a?(Hash)
47
56
  return unless parent_organization.present?
@@ -24,6 +24,10 @@ module PageStructuredData
24
24
  sameAs: same_as
25
25
  )
26
26
  end
27
+
28
+ def warnings
29
+ required_attribute_warnings(name: name)
30
+ end
27
31
  end
28
32
  end
29
33
  end
@@ -4,6 +4,14 @@ module PageStructuredData
4
4
  module PageTypes
5
5
  # Shared helpers for schema.org hash values.
6
6
  module SchemaNode
7
+ def warnings
8
+ []
9
+ end
10
+
11
+ def valid?
12
+ warnings.empty?
13
+ end
14
+
7
15
  private
8
16
 
9
17
  def compact_node(node)
@@ -23,6 +31,12 @@ module PageStructuredData
23
31
  def blank_schema_value?(value)
24
32
  value.nil? || (value.respond_to?(:empty?) && value.empty?)
25
33
  end
34
+
35
+ def required_attribute_warnings(attributes)
36
+ attributes.each_with_object([]) do |(name, value), warnings|
37
+ warnings << "#{name} is required" if blank_schema_value?(value)
38
+ end
39
+ end
26
40
  end
27
41
  end
28
42
  end
@@ -29,13 +29,20 @@ module PageStructuredData
29
29
  end
30
30
 
31
31
  def json_ld
32
- %(
33
- <script type="application/ld+json">
34
- #{to_h.to_json}
35
- </script>
36
- )
32
+ JsonLd.script_tag(to_h)
33
+ end
34
+
35
+ def warnings
36
+ required_attribute_warnings(name: name, url: url) + nested_warnings
37
37
  end
38
38
 
39
+ private
40
+
41
+ def nested_warnings
42
+ return [] unless publisher.respond_to?(:warnings)
43
+
44
+ publisher.warnings.map { |warning| "publisher: #{warning}" }
45
+ end
39
46
  end
40
47
  end
41
48
  end
@@ -2,13 +2,18 @@
2
2
 
3
3
  <% title = page&.page_title %>
4
4
  <% description = page&.description %>
5
+ <% social_description = page&.resolved_social_description %>
5
6
  <% image = page&.resolved_image || default_image_url || nil %>
6
7
  <% canonical_url = page&.canonical_url %>
8
+ <% robots_content = page&.robots_content %>
7
9
 
8
10
  <title><%= title %></title>
9
11
  <% if canonical_url.present? %>
10
12
  <link rel="canonical" href="<%= canonical_url %>">
11
13
  <% end %>
14
+ <% if robots_content.present? %>
15
+ <meta name="robots" content="<%= robots_content %>">
16
+ <% end %>
12
17
 
13
18
  <meta name="title" content="<%= title %>">
14
19
  <% if description.present? %>
@@ -19,8 +24,8 @@
19
24
  <% end %>
20
25
 
21
26
  <meta property="og:title" content="<%= title %>">
22
- <% if description.present? %>
23
- <meta property="og:description" content="<%= description %>">
27
+ <% if social_description.present? %>
28
+ <meta property="og:description" content="<%= social_description %>">
24
29
  <% end %>
25
30
  <% if image.present? %>
26
31
  <meta property="og:image" content="<%= image %>">
@@ -28,8 +33,8 @@
28
33
 
29
34
  <meta name="twitter:card" content="summary_large_image">
30
35
  <meta name="twitter:title" content="<%= title %>">
31
- <% if description.present? %>
32
- <meta name="twitter:description" content="<%= description %>">
36
+ <% if social_description.present? %>
37
+ <meta name="twitter:description" content="<%= social_description %>">
33
38
  <% end %>
34
39
  <% if image.present? %>
35
40
  <meta name="twitter:image" content="<%= image %>">
@@ -1,3 +1,3 @@
1
1
  module PageStructuredData
2
- VERSION = "1.0.13"
2
+ VERSION = "1.0.15"
3
3
  end
@@ -1,6 +1,7 @@
1
1
  require "page_structured_data/version"
2
2
  require "page_structured_data/engine"
3
3
  require_relative "../app/src/page_structured_data/anchors"
4
+ require_relative "../app/src/page_structured_data/json_ld"
4
5
  require_relative "../app/src/page_structured_data/breadcrumbs"
5
6
  require_relative "../app/src/page_structured_data/page_types/schema_node"
6
7
  require_relative "../app/src/page_structured_data/page_types/person"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: page_structured_data
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.13
4
+ version: 1.0.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jey Geethan
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-06 00:00:00.000000000 Z
11
+ date: 2026-08-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -30,10 +30,11 @@ dependencies:
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
32
  version: '9.0'
33
- description: PageStructuredData gives Rails applications a small page object and view
34
- partial for rendering page titles, basic meta tags, Open Graph tags, Twitter card
35
- tags, breadcrumb JSON-LD, article and forum post JSON-LD, Person, Organization,
36
- and WebSite JSON-LD, and public interaction statistics.
33
+ description: PageStructuredData keeps Rails SEO metadata, social sharing tags, and
34
+ schema.org JSON-LD in one page object and one view partial. It renders page titles,
35
+ basic meta tags, Open Graph tags, Twitter Card tags, breadcrumb JSON-LD, article
36
+ and forum post JSON-LD, Person, Organization, and WebSite JSON-LD, and public interaction
37
+ statistics.
37
38
  email:
38
39
  - opensource@rocketapex.com
39
40
  executables: []
@@ -46,6 +47,7 @@ files:
46
47
  - Rakefile
47
48
  - app/src/page_structured_data/anchors.rb
48
49
  - app/src/page_structured_data/breadcrumbs.rb
50
+ - app/src/page_structured_data/json_ld.rb
49
51
  - app/src/page_structured_data/page.rb
50
52
  - app/src/page_structured_data/page_types/article.rb
51
53
  - app/src/page_structured_data/page_types/blog_posting.rb
@@ -91,5 +93,5 @@ requirements: []
91
93
  rubygems_version: 3.3.7
92
94
  signing_key:
93
95
  specification_version: 4
94
- summary: Render SEO, social, and JSON-LD metadata for Rails pages
96
+ summary: One Rails page object for SEO, social, and JSON-LD metadata
95
97
  test_files: []