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 +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +243 -8
- data/Rakefile +5 -0
- data/app/src/page_structured_data/breadcrumbs.rb +4 -5
- data/app/src/page_structured_data/json_ld.rb +22 -0
- data/app/src/page_structured_data/page.rb +34 -4
- data/app/src/page_structured_data/page_types/article.rb +55 -7
- data/app/src/page_structured_data/page_types/discussion_forum_posting.rb +17 -0
- data/app/src/page_structured_data/page_types/interaction_statistic.rb +7 -0
- data/app/src/page_structured_data/page_types/organization.rb +14 -5
- data/app/src/page_structured_data/page_types/person.rb +4 -0
- data/app/src/page_structured_data/page_types/schema_node.rb +14 -0
- data/app/src/page_structured_data/page_types/web_site.rb +12 -5
- data/app/views/page_structured_data/_meta_tags.html.erb +9 -4
- data/lib/page_structured_data/version.rb +1 -1
- data/lib/page_structured_data.rb +1 -0
- metadata +9 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f455392a943824c8c84fbeb1a95e0250d9f959f98c8e5361c5d57d1f38140d91
|
|
4
|
+
data.tar.gz: 306f4a1246e196db8a7e58bb89aa1a73454c3fc873c3431818a1c0b2cdb5bc93
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://rubygems.org/gems/page_structured_data)
|
|
4
4
|
[](MIT-LICENSE)
|
|
5
5
|
|
|
6
|
-
PageStructuredData is a small Rails engine
|
|
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
|
-
|
|
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 `
|
|
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
|
@@ -22,11 +22,10 @@ module PageStructuredData
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def json_ld(current_page_title:)
|
|
25
|
-
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
@@ -33,15 +33,24 @@ module PageStructuredData
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def json_ld
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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?
|
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
23
|
-
<meta property="og:description" content="<%=
|
|
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
|
|
32
|
-
<meta name="twitter:description" content="<%=
|
|
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 %>">
|
data/lib/page_structured_data.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|
|
34
|
-
|
|
35
|
-
tags,
|
|
36
|
-
and WebSite JSON-LD, and public interaction
|
|
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:
|
|
96
|
+
summary: One Rails page object for SEO, social, and JSON-LD metadata
|
|
95
97
|
test_files: []
|