page_structured_data 1.0.12 → 1.0.14

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: 885b125f1d2fed94a1e0bba7d2c70877f0dba9c5f4ebde59c52f970f2fc4fce4
4
- data.tar.gz: 513764c63a8dae5e609d2e92ea1e99dee3fe0555a3727afa24c3d2afdcc64ffd
3
+ metadata.gz: 1e36ab494ebd4db8743deb6d8415e826c2186f7c28a7e2927c8a08df4decabd6
4
+ data.tar.gz: b48c8723a6ad4a3ee5aa44be5e6b36d6764abbd433a3daedb1e3278f32cf7b2b
5
5
  SHA512:
6
- metadata.gz: 1e4530a70cf06b4d81cab2322759500bdb29acb31ef8c33846f40d9095cb981c2272feb0fe6c57b6b5c363915e59ab7300071d618799459d2e34317bfc1434e8
7
- data.tar.gz: 6a900ab594f816e1b2edd833a350a15c0837503af68f3efa0a475ac723e05b21bdc66cbad39384c680a2dfab4dfd173137dd9a05f245f236df450e757f162f98
6
+ metadata.gz: 1c9dd473ced4959c0acc17940b212d0c71b760efe3a0cfe119d74845ab10b76e081363b26d4138f6b5d66a5e62d0f7c36623c5603ac78ec8565350e2cd53dbcf
7
+ data.tar.gz: eefd736852c02d3ec1f02a58e7911f931d8882ce09854364c9b028cd1a11aa1872c3fe1a79beb4c379b8468e1b3db7871a00331dc47522573da9abc64bfa6dce
data/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to this project are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 1.0.14 - 2026-07-05
8
+
9
+ - Add page-level `robots` meta tag support.
10
+ - Add richer optional schema fields for article-like page types.
11
+ - Add README common pattern examples.
12
+ - Add focused rendered metadata output coverage.
13
+ - Add soft validation helpers with `warnings` and `valid?`.
14
+ - Improve README and gem metadata positioning.
15
+
16
+ ## 1.0.13 - 2026-05-06
17
+
18
+ - Add `PageStructuredData::PageTypes::Person` for reusable schema.org person values.
19
+ - Accept richer article author values and omit blank nested schema fields.
20
+ - Add page-level `base_app_name` and `render_breadcrumb_json_ld` options.
21
+ - Omit blank description and image meta tags.
22
+
7
23
  ## 1.0.12 - 2026-05-06
8
24
 
9
25
  - Add reusable interaction statistics for article-like page types.
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
 
@@ -16,6 +18,7 @@ It helps Rails applications render:
16
18
  - Article structured data for `BlogPosting` and `NewsArticle`
17
19
  - Discussion forum post structured data
18
20
  - Interaction statistics for public engagement counts
21
+ - Reusable Person structured data
19
22
  - Organization and WebSite structured data
20
23
 
21
24
  ## Requirements
@@ -97,7 +100,8 @@ Set `@page_meta` in the controller or view before the layout renders:
97
100
  description: "Welcome to my page",
98
101
  image: image_url("social/home.png"),
99
102
  canonical_url: home_url,
100
- fallback_image: image_url("social/default.png")
103
+ fallback_image: image_url("social/default.png"),
104
+ robots: "index,follow"
101
105
  )
102
106
  ```
103
107
 
@@ -144,6 +148,7 @@ PageStructuredData includes page types for:
144
148
  - [`BlogPosting`](https://schema.org/BlogPosting)
145
149
  - [`NewsArticle`](https://schema.org/NewsArticle)
146
150
  - [`DiscussionForumPosting`](https://schema.org/DiscussionForumPosting)
151
+ - [`Person`](https://schema.org/Person)
147
152
  - [`Organization`](https://schema.org/Organization)
148
153
  - [`WebSite`](https://schema.org/WebSite)
149
154
 
@@ -263,6 +268,182 @@ website_page_type = PageStructuredData::PageTypes::WebSite.new(
263
268
  )
264
269
  ```
265
270
 
271
+ ## Common Patterns
272
+
273
+ ### Article Page
274
+
275
+ Use `BlogPosting` or `NewsArticle` when a page represents editorial content:
276
+
277
+ ```ruby
278
+ publisher = PageStructuredData::PageTypes::Organization.new(
279
+ name: "Example",
280
+ url: root_url
281
+ )
282
+
283
+ author = PageStructuredData::PageTypes::Person.new(
284
+ name: @article.author.name,
285
+ url: author_url(@article.author)
286
+ )
287
+
288
+ article_page_type = PageStructuredData::PageTypes::BlogPosting.new(
289
+ headline: @article.title,
290
+ description: @article.summary,
291
+ article_body: @article.body.to_plain_text,
292
+ url: article_url(@article),
293
+ main_entity_of_page: article_url(@article),
294
+ publisher: publisher,
295
+ article_section: @article.category.name,
296
+ keywords: @article.tags.pluck(:name),
297
+ word_count: @article.word_count,
298
+ in_language: "en",
299
+ published_at: @article.published_at,
300
+ updated_at: @article.updated_at,
301
+ authors: [author],
302
+ image: url_for(@article.cover_image),
303
+ likes_count: @article.likes_count
304
+ )
305
+
306
+ @page_meta = PageStructuredData::Page.new(
307
+ title: @article.title,
308
+ description: @article.summary,
309
+ image: url_for(@article.cover_image),
310
+ canonical_url: article_url(@article),
311
+ breadcrumb: article_breadcrumbs,
312
+ page_type: article_page_type
313
+ )
314
+ ```
315
+
316
+ ### Homepage
317
+
318
+ Use both `Organization` and `WebSite` when the page represents the public home of a site or organization:
319
+
320
+ ```ruby
321
+ founder = PageStructuredData::PageTypes::Person.new(
322
+ name: "Jane Doe",
323
+ url: "https://example.com/jane"
324
+ )
325
+
326
+ organization = PageStructuredData::PageTypes::Organization.new(
327
+ name: "Example",
328
+ url: root_url,
329
+ description: "Useful software from Example",
330
+ logo: image_url("logo.png"),
331
+ same_as: ["https://github.com/example"],
332
+ founder: founder
333
+ )
334
+
335
+ website = PageStructuredData::PageTypes::WebSite.new(
336
+ name: "Example",
337
+ url: root_url,
338
+ description: "Useful software from Example",
339
+ publisher: organization
340
+ )
341
+
342
+ @page_meta = PageStructuredData::Page.new(
343
+ title: "Example",
344
+ description: "Useful software from Example",
345
+ image: image_url("social/home.png"),
346
+ canonical_url: root_url,
347
+ page_types: [organization, website],
348
+ render_breadcrumb_json_ld: false
349
+ )
350
+ ```
351
+
352
+ ### Forum Or Community Post
353
+
354
+ Use `DiscussionForumPosting` for public, user-authored, timestamped posts:
355
+
356
+ ```ruby
357
+ post_author = PageStructuredData::PageTypes::Person.new(
358
+ name: @post.user.name,
359
+ url: user_url(@post.user)
360
+ )
361
+
362
+ post_page_type = PageStructuredData::PageTypes::DiscussionForumPosting.new(
363
+ headline: @post.title,
364
+ text: @post.content_plaintext,
365
+ url: post_url(@post),
366
+ published_at: @post.created_at,
367
+ updated_at: @post.updated_at,
368
+ authors: [post_author],
369
+ comments_count: @post.comments_count
370
+ )
371
+
372
+ @page_meta = PageStructuredData::Page.new(
373
+ title: @post.title,
374
+ description: @post.excerpt,
375
+ canonical_url: post_url(@post),
376
+ page_type: post_page_type
377
+ )
378
+ ```
379
+
380
+ Only pass engagement counts that are public and visible on the rendered page.
381
+
382
+ ### Page-Local Site Name
383
+
384
+ Override or suppress the global app name for one page:
385
+
386
+ ```ruby
387
+ PageStructuredData.base_app_name = "Example"
388
+
389
+ PageStructuredData::Page.new(
390
+ title: "Docs",
391
+ base_app_name: "Developer Docs"
392
+ ).page_title
393
+ # => "Docs - Developer Docs"
394
+
395
+ PageStructuredData::Page.new(
396
+ title: "Minimal",
397
+ base_app_name: ""
398
+ ).page_title
399
+ # => "Minimal"
400
+ ```
401
+
402
+ ### Breadcrumb JSON-LD Control
403
+
404
+ Control generated breadcrumb JSON-LD per page:
405
+
406
+ ```ruby
407
+ PageStructuredData::Page.new(
408
+ title: "Landing Page",
409
+ render_breadcrumb_json_ld: false
410
+ )
411
+ ```
412
+
413
+ When the global default is disabled, a page can still opt in:
414
+
415
+ ```ruby
416
+ PageStructuredData.render_default_breadcrumb_json_ld = false
417
+
418
+ PageStructuredData::Page.new(
419
+ title: "Standalone Page",
420
+ render_breadcrumb_json_ld: true
421
+ )
422
+ ```
423
+
424
+ ### Paginated Archive
425
+
426
+ Use `robots` for archive, search, filtered, or paginated pages that should be crawlable but not indexed:
427
+
428
+ ```ruby
429
+ @page_meta = PageStructuredData::Page.new(
430
+ title: "Articles",
431
+ extra_title: "Page #{@pagy.page}",
432
+ description: "Browse articles from Example",
433
+ canonical_url: articles_url(page: @pagy.page),
434
+ robots: "noindex,follow"
435
+ )
436
+ ```
437
+
438
+ `robots` can also be passed as an array:
439
+
440
+ ```ruby
441
+ PageStructuredData::Page.new(
442
+ title: "Articles",
443
+ robots: ["noindex", "follow"]
444
+ )
445
+ ```
446
+
266
447
  ## API Reference
267
448
 
268
449
  ### `PageStructuredData::Page`
@@ -277,15 +458,44 @@ PageStructuredData::Page.new(
277
458
  page_type: nil,
278
459
  page_types: nil,
279
460
  canonical_url: nil,
280
- fallback_image: nil
461
+ fallback_image: nil,
462
+ base_app_name: nil,
463
+ render_breadcrumb_json_ld: nil,
464
+ robots: nil
281
465
  )
282
466
  ```
283
467
 
468
+ `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.
469
+ `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`.
470
+ `robots` can be a string or array and renders a `<meta name="robots">` tag when present.
471
+
284
472
  Important methods:
285
473
 
286
474
  - `page_title`: returns the composed page title.
287
475
  - `json_lds`: returns the JSON-LD script tags for breadcrumbs and page type data.
288
476
  - `resolved_image`: returns `image` or `fallback_image`.
477
+ - `robots_content`: returns the rendered robots directives.
478
+ - `warnings`: returns soft validation warnings for the page and page types.
479
+ - `valid?`: returns `true` when `warnings` is empty.
480
+
481
+ ### Soft Validation
482
+
483
+ Page objects and page type objects expose non-blocking validation helpers:
484
+
485
+ ```ruby
486
+ page_type = PageStructuredData::PageTypes::Organization.new(
487
+ name: nil,
488
+ url: nil
489
+ )
490
+
491
+ page_type.warnings
492
+ # => ["name is required", "url is required"]
493
+
494
+ page_type.valid?
495
+ # => false
496
+ ```
497
+
498
+ `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.
289
499
 
290
500
  ### `PageStructuredData::Breadcrumbs`
291
501
 
@@ -313,9 +523,16 @@ PageStructuredData::PageTypes::BlogPosting.new(
313
523
  images: [],
314
524
  authors: [],
315
525
  image: nil,
526
+ description: nil,
316
527
  article_body: nil,
317
528
  text: nil,
318
529
  url: nil,
530
+ main_entity_of_page: nil,
531
+ publisher: nil,
532
+ article_section: nil,
533
+ keywords: nil,
534
+ word_count: nil,
535
+ in_language: nil,
319
536
  interaction_statistics: [],
320
537
  likes_count: nil,
321
538
  comments_count: nil,
@@ -331,9 +548,16 @@ PageStructuredData::PageTypes::NewsArticle.new(
331
548
  images: [],
332
549
  authors: [],
333
550
  image: nil,
551
+ description: nil,
334
552
  article_body: nil,
335
553
  text: nil,
336
554
  url: nil,
555
+ main_entity_of_page: nil,
556
+ publisher: nil,
557
+ article_section: nil,
558
+ keywords: nil,
559
+ word_count: nil,
560
+ in_language: nil,
337
561
  interaction_statistics: [],
338
562
  likes_count: nil,
339
563
  comments_count: nil,
@@ -341,9 +565,12 @@ PageStructuredData::PageTypes::NewsArticle.new(
341
565
  )
342
566
  ```
343
567
 
344
- `authors` should be an array of hashes with `:name` and `:url` keys.
568
+ `authors` can be an array of hashes, `PageStructuredData::PageTypes::Person` objects, or other objects that respond to `to_h`.
345
569
  `image` is a convenience option for one image URL. Use `images` when passing multiple image URLs.
346
570
  `text` is an alias for `article_body`.
571
+ `main_entity_of_page` can be a URL, hash, or object that responds to `to_h`.
572
+ `publisher` can be a hash or another page type that responds to `to_h`, such as `PageStructuredData::PageTypes::Organization`.
573
+ `keywords` can be a string or an array.
347
574
  `interaction_statistics` should be an array of `PageStructuredData::PageTypes::InteractionStatistic` objects or schema-compatible hashes.
348
575
 
349
576
  Important methods:
@@ -359,9 +586,16 @@ PageStructuredData::PageTypes::DiscussionForumPosting.new(
359
586
  images: [],
360
587
  authors: [],
361
588
  image: nil,
589
+ description: nil,
362
590
  article_body: nil,
363
591
  text: nil,
364
592
  url: nil,
593
+ main_entity_of_page: nil,
594
+ publisher: nil,
595
+ article_section: nil,
596
+ keywords: nil,
597
+ word_count: nil,
598
+ in_language: nil,
365
599
  interaction_statistics: [],
366
600
  likes_count: nil,
367
601
  comments_count: nil,
@@ -393,6 +627,30 @@ Important methods:
393
627
 
394
628
  - `to_h`: returns a structured hash for `InteractionCounter` JSON-LD.
395
629
 
630
+ ### Person
631
+
632
+ ```ruby
633
+ PageStructuredData::PageTypes::Person.new(
634
+ name:,
635
+ url: nil,
636
+ image: nil,
637
+ same_as: []
638
+ )
639
+ ```
640
+
641
+ Use `Person` for article authors, organization founders, and other schema.org person values:
642
+
643
+ ```ruby
644
+ author = PageStructuredData::PageTypes::Person.new(
645
+ name: "Jane Doe",
646
+ url: "https://example.com/jane"
647
+ )
648
+ ```
649
+
650
+ Important methods:
651
+
652
+ - `to_h`: returns a compact structured hash for `Person` JSON-LD.
653
+
396
654
  ### Organization Page Type
397
655
 
398
656
  ```ruby
@@ -4,11 +4,11 @@ module PageStructuredData
4
4
  # Basic page metadata for any page
5
5
  class Page
6
6
  attr_reader :title, :description, :image, :extra_title, :breadcrumb, :page_type, :page_types, :canonical_url,
7
- :fallback_image
7
+ :fallback_image, :base_app_name, :render_breadcrumb_json_ld, :robots
8
8
 
9
9
  def initialize(title:, 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)
11
+ fallback_image: nil, base_app_name: nil, render_breadcrumb_json_ld: nil, robots: nil)
12
12
  @title = title
13
13
  @description = description
14
14
  @image = image
@@ -18,6 +18,9 @@ module PageStructuredData
18
18
  @page_types = page_types
19
19
  @canonical_url = canonical_url
20
20
  @fallback_image = fallback_image
21
+ @base_app_name = base_app_name
22
+ @render_breadcrumb_json_ld = render_breadcrumb_json_ld
23
+ @robots = robots
21
24
  end
22
25
 
23
26
  def title_with_hierarchies
@@ -28,8 +31,8 @@ module PageStructuredData
28
31
 
29
32
  def page_title
30
33
  result = title_with_hierarchies.join(separator)
31
- if base_app_name.present?
32
- result += separator + base_app_name
34
+ if resolved_base_app_name.present?
35
+ result += separator + resolved_base_app_name
33
36
  end
34
37
  result
35
38
  end
@@ -45,20 +48,53 @@ module PageStructuredData
45
48
  image || fallback_image
46
49
  end
47
50
 
51
+ def robots_content
52
+ Array(robots).compact.join(',')
53
+ end
54
+
55
+ def warnings
56
+ page_warnings + page_type_warnings
57
+ end
58
+
59
+ def valid?
60
+ warnings.empty?
61
+ end
62
+
48
63
  private
49
64
 
50
65
  def resolved_page_types
51
66
  Array.wrap(page_types.presence || page_type).compact
52
67
  end
53
68
 
69
+ def page_warnings
70
+ title.present? ? [] : ['title is required']
71
+ end
72
+
73
+ def page_type_warnings
74
+ resolved_page_types.each_with_index.flat_map do |resolved_page_type, index|
75
+ next [] unless resolved_page_type.respond_to?(:warnings)
76
+
77
+ resolved_page_type.warnings.map { |warning| "page type #{index + 1}: #{warning}" }
78
+ end
79
+ end
80
+
54
81
  def breadcrumb_json_ld
82
+ return if render_breadcrumb_json_ld == false
55
83
  return breadcrumb.json_ld(current_page_title: title) if breadcrumb.present?
56
- return unless PageStructuredData.render_default_breadcrumb_json_ld
84
+ return unless render_breadcrumb_json_ld?
57
85
 
58
86
  Breadcrumbs.new.json_ld(current_page_title: title)
59
87
  end
60
88
 
61
- def base_app_name
89
+ def render_breadcrumb_json_ld?
90
+ return render_breadcrumb_json_ld unless render_breadcrumb_json_ld.nil?
91
+
92
+ PageStructuredData.render_default_breadcrumb_json_ld
93
+ end
94
+
95
+ def resolved_base_app_name
96
+ return base_app_name unless base_app_name.nil?
97
+
62
98
  PageStructuredData.base_app_name
63
99
  end
64
100
 
@@ -4,18 +4,30 @@ module PageStructuredData
4
4
  module PageTypes
5
5
  # Shared structured data for schema.org article-like page types.
6
6
  class Article
7
- attr_reader :headline, :images, :published_at, :updated_at, :authors, :article_body, :url,
7
+ include SchemaNode
8
+
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,
8
11
  :interaction_statistics, :likes_count, :comments_count, :shares_count
9
12
 
10
13
  def initialize(headline:, published_at:, updated_at:, images: [], authors: [], image: nil, article_body: nil, text: nil,
11
- 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)
12
17
  @headline = headline
13
18
  @images = image.present? ? Array(image) : Array(images)
14
19
  @published_at = published_at
15
20
  @updated_at = updated_at
16
21
  @authors = Array(authors)
22
+ @description = description
17
23
  @article_body = article_body || text
18
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
19
31
  @interaction_statistics = Array(interaction_statistics)
20
32
  @likes_count = likes_count
21
33
  @comments_count = comments_count
@@ -30,17 +42,18 @@ module PageStructuredData
30
42
  image: images,
31
43
  datePublished: published_at,
32
44
  dateModified: updated_at,
33
- author: authors.map do |author|
34
- {
35
- '@type': 'Person',
36
- name: author[:name],
37
- url: author[:url],
38
- }
39
- end,
45
+ author: authors.map { |author| author_to_h(author) },
40
46
  }
41
47
 
48
+ node[:description] = description if description.present?
42
49
  node[:articleBody] = article_body if article_body.present?
43
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?
44
57
  node[:interactionStatistic] = interaction_statistics_to_h if interaction_statistics_to_h.any?
45
58
 
46
59
  node
@@ -54,12 +67,51 @@ module PageStructuredData
54
67
  )
55
68
  end
56
69
 
70
+ def warnings
71
+ required_attribute_warnings(
72
+ headline: headline,
73
+ published_at: published_at,
74
+ updated_at: updated_at
75
+ ) + author_warnings + publisher_warnings + interaction_statistic_warnings
76
+ end
77
+
57
78
  private
58
79
 
59
80
  def schema_type
60
81
  raise NotImplementedError, "#{self.class.name} must define #schema_type"
61
82
  end
62
83
 
84
+ def author_to_h(author)
85
+ return object_to_h(author) if author.respond_to?(:to_h) && !author.is_a?(Hash)
86
+
87
+ compact_node(
88
+ '@type': 'Person',
89
+ name: author[:name] || author['name'],
90
+ url: author[:url] || author['url'],
91
+ image: author[:image] || author['image'],
92
+ sameAs: author[:same_as] || author[:sameAs] || author['same_as'] || author['sameAs']
93
+ )
94
+ end
95
+
96
+ def author_warnings
97
+ authors.each_with_index.flat_map do |author, index|
98
+ if author.respond_to?(:warnings)
99
+ author.warnings.map { |warning| "author #{index + 1}: #{warning}" }
100
+ else
101
+ author_hash = object_to_h(author) || {}
102
+ required_attribute_warnings(name: author_hash[:name] || author_hash['name']).map do |warning|
103
+ "author #{index + 1}: #{warning}"
104
+ end
105
+ end
106
+ end
107
+ end
108
+
109
+ def publisher_warnings
110
+ return [] unless publisher.respond_to?(:warnings)
111
+
112
+ publisher.warnings.map { |warning| "publisher: #{warning}" }
113
+ end
114
+
63
115
  def interaction_statistics_to_h
64
116
  @interaction_statistics_to_h ||= all_interaction_statistics.map do |interaction_statistic|
65
117
  if interaction_statistic.respond_to?(:to_h)
@@ -87,6 +139,14 @@ module PageStructuredData
87
139
 
88
140
  InteractionStatistic.new(interaction_type: interaction_type, user_interaction_count: count)
89
141
  end
142
+
143
+ def interaction_statistic_warnings
144
+ all_interaction_statistics.each_with_index.flat_map do |interaction_statistic, index|
145
+ next [] unless interaction_statistic.respond_to?(:warnings)
146
+
147
+ interaction_statistic.warnings.map { |warning| "interaction statistic #{index + 1}: #{warning}" }
148
+ end
149
+ end
90
150
  end
91
151
  end
92
152
  end
@@ -4,6 +4,8 @@ module PageStructuredData
4
4
  module PageTypes
5
5
  # schema.org InteractionCounter structured data.
6
6
  class InteractionStatistic
7
+ include SchemaNode
8
+
7
9
  ACTION_TYPES = {
8
10
  like: 'LikeAction',
9
11
  likes: 'LikeAction',
@@ -34,15 +36,19 @@ module PageStructuredData
34
36
  end
35
37
 
36
38
  def to_h
37
- node = {
39
+ compact_node(
38
40
  '@type': 'InteractionCounter',
39
41
  interactionType: interaction_type_to_h,
40
42
  userInteractionCount: user_interaction_count,
41
- }
42
-
43
- node[:interactionService] = object_to_h(interaction_service) if interaction_service.present?
43
+ interactionService: object_to_h(interaction_service)
44
+ )
45
+ end
44
46
 
45
- node
47
+ def warnings
48
+ required_attribute_warnings(
49
+ interaction_type: interaction_type,
50
+ user_interaction_count: user_interaction_count
51
+ )
46
52
  end
47
53
 
48
54
  private
@@ -54,12 +60,6 @@ module PageStructuredData
54
60
 
55
61
  { '@type': type }
56
62
  end
57
-
58
- def object_to_h(object)
59
- return object.to_h if object.respond_to?(:to_h)
60
-
61
- object
62
- end
63
63
  end
64
64
  end
65
65
  end
@@ -4,6 +4,8 @@ module PageStructuredData
4
4
  module PageTypes
5
5
  # Organization structured data for a page
6
6
  class Organization
7
+ include SchemaNode
8
+
7
9
  attr_reader :name, :url, :description, :logo, :same_as, :parent_organization, :founder
8
10
 
9
11
  def initialize(name:, url:, description: nil, logo: nil, same_as: [], parent_organization: nil, founder: nil)
@@ -11,33 +13,23 @@ module PageStructuredData
11
13
  @url = url
12
14
  @description = description
13
15
  @logo = logo
14
- @same_as = same_as
16
+ @same_as = Array(same_as)
15
17
  @parent_organization = parent_organization
16
18
  @founder = founder
17
19
  end
18
20
 
19
21
  def to_h # rubocop:disable Metrics/MethodLength
20
- node = {
22
+ compact_node(
21
23
  '@context': 'https://schema.org',
22
24
  '@type': 'Organization',
23
- }
24
-
25
- node[:name] = name
26
- node[:url] = url
27
- node[:description] = description if description.present?
28
- node[:logo] = logo if logo.present?
29
- node[:sameAs] = same_as if same_as.present?
30
- node[:founder] = founder_to_h if founder.present?
31
-
32
- if parent_organization.present?
33
- node[:parentOrganization] = {
34
- '@type': 'Organization',
35
- name: parent_organization[:name],
36
- url: parent_organization[:url],
37
- }
38
- end
39
-
40
- node
25
+ name: name,
26
+ url: url,
27
+ description: description,
28
+ logo: logo,
29
+ sameAs: same_as,
30
+ founder: object_to_h(founder),
31
+ parentOrganization: parent_organization_to_h
32
+ )
41
33
  end
42
34
 
43
35
  def json_ld
@@ -48,12 +40,30 @@ module PageStructuredData
48
40
  )
49
41
  end
50
42
 
43
+ def warnings
44
+ required_attribute_warnings(name: name, url: url) + nested_warnings
45
+ end
46
+
51
47
  private
52
48
 
53
- def founder_to_h
54
- return founder.to_h if founder.respond_to?(:to_h)
49
+ def nested_warnings
50
+ [founder, parent_organization].each_with_index.flat_map do |node, index|
51
+ next [] unless node.respond_to?(:warnings)
55
52
 
56
- founder
53
+ prefix = index.zero? ? 'founder' : 'parent organization'
54
+ node.warnings.map { |warning| "#{prefix}: #{warning}" }
55
+ end
56
+ end
57
+
58
+ def parent_organization_to_h
59
+ return object_to_h(parent_organization) if parent_organization.respond_to?(:to_h) && !parent_organization.is_a?(Hash)
60
+ return unless parent_organization.present?
61
+
62
+ compact_node(
63
+ '@type': 'Organization',
64
+ name: parent_organization[:name] || parent_organization['name'],
65
+ url: parent_organization[:url] || parent_organization['url']
66
+ )
57
67
  end
58
68
  end
59
69
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PageStructuredData
4
+ module PageTypes
5
+ # schema.org Person structured data.
6
+ class Person
7
+ include SchemaNode
8
+
9
+ attr_reader :name, :url, :image, :same_as
10
+
11
+ def initialize(name:, url: nil, image: nil, same_as: [])
12
+ @name = name
13
+ @url = url
14
+ @image = image
15
+ @same_as = Array(same_as)
16
+ end
17
+
18
+ def to_h
19
+ compact_node(
20
+ '@type': 'Person',
21
+ name: name,
22
+ url: url,
23
+ image: image,
24
+ sameAs: same_as
25
+ )
26
+ end
27
+
28
+ def warnings
29
+ required_attribute_warnings(name: name)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PageStructuredData
4
+ module PageTypes
5
+ # Shared helpers for schema.org hash values.
6
+ module SchemaNode
7
+ def warnings
8
+ []
9
+ end
10
+
11
+ def valid?
12
+ warnings.empty?
13
+ end
14
+
15
+ private
16
+
17
+ def compact_node(node)
18
+ node.each_with_object({}) do |(key, value), compacted|
19
+ next if blank_schema_value?(value)
20
+
21
+ compacted[key] = value
22
+ end
23
+ end
24
+
25
+ def object_to_h(object)
26
+ return object.to_h if object.respond_to?(:to_h)
27
+
28
+ object
29
+ end
30
+
31
+ def blank_schema_value?(value)
32
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
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
40
+ end
41
+ end
42
+ end
@@ -4,6 +4,8 @@ module PageStructuredData
4
4
  module PageTypes
5
5
  # WebSite structured data for a page
6
6
  class WebSite
7
+ include SchemaNode
8
+
7
9
  attr_reader :name, :url, :description, :publisher, :potential_action
8
10
 
9
11
  def initialize(name:, url:, description: nil, publisher: nil, potential_action: nil)
@@ -15,18 +17,15 @@ module PageStructuredData
15
17
  end
16
18
 
17
19
  def to_h
18
- node = {
20
+ compact_node(
19
21
  '@context': 'https://schema.org',
20
22
  '@type': 'WebSite',
21
23
  name: name,
22
24
  url: url,
23
- }
24
-
25
- node[:description] = description if description.present?
26
- node[:publisher] = publisher_to_h if publisher.present?
27
- node[:potentialAction] = potential_action if potential_action.present?
28
-
29
- node
25
+ description: description,
26
+ publisher: object_to_h(publisher),
27
+ potentialAction: object_to_h(potential_action)
28
+ )
30
29
  end
31
30
 
32
31
  def json_ld
@@ -37,12 +36,16 @@ module PageStructuredData
37
36
  )
38
37
  end
39
38
 
39
+ def warnings
40
+ required_attribute_warnings(name: name, url: url) + nested_warnings
41
+ end
42
+
40
43
  private
41
44
 
42
- def publisher_to_h
43
- return publisher.to_h if publisher.respond_to?(:to_h)
45
+ def nested_warnings
46
+ return [] unless publisher.respond_to?(:warnings)
44
47
 
45
- publisher
48
+ publisher.warnings.map { |warning| "publisher: #{warning}" }
46
49
  end
47
50
  end
48
51
  end
@@ -4,23 +4,39 @@
4
4
  <% description = page&.description %>
5
5
  <% image = page&.resolved_image || default_image_url || nil %>
6
6
  <% canonical_url = page&.canonical_url %>
7
+ <% robots_content = page&.robots_content %>
7
8
 
8
9
  <title><%= title %></title>
9
10
  <% if canonical_url.present? %>
10
11
  <link rel="canonical" href="<%= canonical_url %>">
11
12
  <% end %>
13
+ <% if robots_content.present? %>
14
+ <meta name="robots" content="<%= robots_content %>">
15
+ <% end %>
12
16
 
13
17
  <meta name="title" content="<%= title %>">
18
+ <% if description.present? %>
14
19
  <meta name="description" content="<%= description %>">
20
+ <% end %>
21
+ <% if image.present? %>
15
22
  <meta name="image" content="<%= image %>">
23
+ <% end %>
16
24
 
17
25
  <meta property="og:title" content="<%= title %>">
26
+ <% if description.present? %>
18
27
  <meta property="og:description" content="<%= description %>">
28
+ <% end %>
29
+ <% if image.present? %>
19
30
  <meta property="og:image" content="<%= image %>">
31
+ <% end %>
20
32
 
21
33
  <meta name="twitter:card" content="summary_large_image">
22
34
  <meta name="twitter:title" content="<%= title %>">
35
+ <% if description.present? %>
23
36
  <meta name="twitter:description" content="<%= description %>">
37
+ <% end %>
38
+ <% if image.present? %>
24
39
  <meta name="twitter:image" content="<%= image %>">
40
+ <% end %>
25
41
 
26
42
  <%= page&.json_lds&.html_safe %>
@@ -1,3 +1,3 @@
1
1
  module PageStructuredData
2
- VERSION = "1.0.12"
2
+ VERSION = "1.0.14"
3
3
  end
@@ -2,6 +2,8 @@ require "page_structured_data/version"
2
2
  require "page_structured_data/engine"
3
3
  require_relative "../app/src/page_structured_data/anchors"
4
4
  require_relative "../app/src/page_structured_data/breadcrumbs"
5
+ require_relative "../app/src/page_structured_data/page_types/schema_node"
6
+ require_relative "../app/src/page_structured_data/page_types/person"
5
7
  require_relative "../app/src/page_structured_data/page_types/interaction_statistic"
6
8
  require_relative "../app/src/page_structured_data/page_types/article"
7
9
  require_relative "../app/src/page_structured_data/page_types/blog_posting"
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.12
4
+ version: 1.0.14
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-07-05 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, Organization JSON-LD,
36
- 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: []
@@ -53,6 +54,8 @@ files:
53
54
  - app/src/page_structured_data/page_types/interaction_statistic.rb
54
55
  - app/src/page_structured_data/page_types/news_article.rb
55
56
  - app/src/page_structured_data/page_types/organization.rb
57
+ - app/src/page_structured_data/page_types/person.rb
58
+ - app/src/page_structured_data/page_types/schema_node.rb
56
59
  - app/src/page_structured_data/page_types/web_site.rb
57
60
  - app/views/page_structured_data/_meta_tags.html.erb
58
61
  - config/routes.rb
@@ -89,5 +92,5 @@ requirements: []
89
92
  rubygems_version: 3.3.7
90
93
  signing_key:
91
94
  specification_version: 4
92
- summary: Render SEO, social, and JSON-LD metadata for Rails pages
95
+ summary: One Rails page object for SEO, social, and JSON-LD metadata
93
96
  test_files: []