jekyll-structured-content 0.2.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6bb8d4fb4c737c5628ac46602e10d8d9cff60bd549dd5ae1c37c8539e7326295
4
+ data.tar.gz: b742f29446ec10f1a052dd730ba7428f5ba39425c9f679fce8f4d1fad4443d27
5
+ SHA512:
6
+ metadata.gz: acc9cc12cfe3809e578cdb8287e0a4eaf8cef7dfeea33e91b01f8031032ad683f676724d39c6e56658755de9b2db413ee4b40d60c839508af4be050bba4c36ad
7
+ data.tar.gz: 0d79e70c5fe6397b5270d919963eafe3b62f66e2b397b1e38f4c7c5732fedcd7714e03cd5610293c8ee299f983022307915d651d97cf5873a247bcfc951ffeb7
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ - Added `schema_json_ld` tag for post, page, and project schema output
6
+ - Added FAQ JSON-LD support
7
+ - Omit optional schema fields like `dateModified` when values are absent
8
+ - Clarified GitHub Pages compatibility caveats
9
+
10
+ ## 0.1.0
11
+
12
+ - Initial plugin scaffold
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,23 @@
1
+ # Contributing
2
+
3
+ Thanks for your interest in improving `jekyll-structured-content`.
4
+
5
+ ## Before submitting changes
6
+
7
+ 1. Keep the scope narrow and focused on structured data helpers.
8
+ 2. Avoid adding theme-specific rendering assumptions.
9
+ 3. Add or update tests for behavior changes.
10
+
11
+ ## Local development
12
+
13
+ ```bash
14
+ bundle install
15
+ ruby -Itest test/faq_json_ld_tag_test.rb
16
+ ruby -Itest test/schema_json_ld_tag_test.rb
17
+ ```
18
+
19
+ ## Design philosophy
20
+
21
+ - structured data in the plugin
22
+ - visible theme markup in the site
23
+ - explicit behavior over magic
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jason Chance
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # jekyll-structured-content
2
+
3
+ A lightweight Jekyll plugin for structured data helpers in content-driven Jekyll sites.
4
+
5
+ - FAQPage JSON-LD generation
6
+ - JSON-LD helpers for posts, pages, and project-like content
7
+
8
+ ## Scope
9
+
10
+ This package is intentionally narrow. It focuses on generating clean, reusable structured output from frontmatter and templates without prescribing site copy, citation systems, or a full SEO strategy.
11
+
12
+ ## Who this is for
13
+
14
+ This plugin is for people who:
15
+
16
+ - publish articles or reference content with Jekyll
17
+ - want cleaner machine-readable structured data
18
+ - prefer to keep visible theme markup in the site itself
19
+ - want a small plugin instead of a full SEO framework
20
+
21
+ ## Features
22
+
23
+ 1. Emit FAQPage JSON-LD from FAQ frontmatter.
24
+ 2. Emit JSON-LD for post, page, and project-like content via a Liquid tag.
25
+ 3. Leave visible FAQ and theme markup to the site.
26
+
27
+ ## What this plugin does not do
28
+
29
+ - It does **not** render your visible FAQ accordion or theme markup.
30
+ - It does **not** manage page titles, meta descriptions, or Open Graph tags.
31
+ - It does **not** integrate with citation or bibliography plugins.
32
+ - It does **not** replace a full SEO plugin.
33
+
34
+ ## GitHub Pages compatibility
35
+
36
+ This plugin is compatible with Jekyll sites deployed to GitHub Pages **when the site is built through a custom GitHub Actions workflow**.
37
+
38
+ It should **not** be described as compatible with the default GitHub Pages safe-mode build, because custom plugins are not loaded there.
39
+
40
+ ## Installation
41
+
42
+ Add the gem to your `Gemfile`:
43
+
44
+ ```ruby
45
+ gem "jekyll-structured-content"
46
+ ```
47
+
48
+ Then add it to `_config.yml`:
49
+
50
+ ```yml
51
+ plugins:
52
+ - jekyll-structured-content
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ ### FAQ JSON-LD
58
+
59
+ ```liquid
60
+ <script type="application/ld+json">{% faq_json_ld %}</script>
61
+ ```
62
+
63
+ This reads from page frontmatter like:
64
+
65
+ ```yml
66
+ faq:
67
+ - question: "What is this?"
68
+ answer: "A short answer."
69
+ - question: "Can I use title/description instead?"
70
+ answer: "Yes, FAQ items may also use title/description keys."
71
+ ```
72
+
73
+ ### Post schema JSON-LD
74
+
75
+ ```liquid
76
+ <script type="application/ld+json">{% schema_json_ld post %}</script>
77
+ ```
78
+
79
+ ### Page schema JSON-LD
80
+
81
+ ```liquid
82
+ <script type="application/ld+json">{% schema_json_ld page %}</script>
83
+ ```
84
+
85
+ ### Project schema JSON-LD
86
+
87
+ ```liquid
88
+ <script type="application/ld+json">{% schema_json_ld project %}</script>
89
+ ```
90
+
91
+ ## What this plugin expects
92
+
93
+ This plugin works best when your documents include standard Jekyll frontmatter like:
94
+
95
+ - `title`
96
+ - `description`
97
+ - `date` for posts
98
+ - `url`
99
+ - `image`
100
+ - `category`
101
+ - `tags`
102
+ - `faq`
103
+
104
+ At the site level, it looks for these values when available:
105
+
106
+ - `site.data.settings.title`
107
+ - `site.data.settings.logo`
108
+ - `site.data.settings.author.author_name`
109
+ - `site.data.settings.description`
110
+
111
+ If these settings are missing, the plugin falls back where it can, but richer structured output depends on good site metadata.
112
+
113
+ ## Output behavior
114
+
115
+ - `faq_json_ld` emits a `FAQPage`
116
+ - `schema_json_ld post` emits a `BlogPosting`
117
+ - `schema_json_ld page` emits a `WebPage`, or `ProfilePage` for `/about/`
118
+ - `schema_json_ld project` emits a `CreativeWork`
119
+ - optional fields like `dateModified` are omitted when not present
120
+
121
+ ## Live example
122
+
123
+ Production example using post schema plus FAQPage schema:
124
+
125
+ - https://jasonchance.com/blog/the-georgia-rural-zone-playbook-how-small-downtowns-stack-three-state-tax-credits/
126
+
127
+ ## Tested assumptions
128
+
129
+ - Jekyll 4.x
130
+ - Liquid 4.x
131
+ - local Jekyll builds
132
+ - GitHub Pages deployments built through GitHub Actions
133
+
134
+ ## Development
135
+
136
+ Install dependencies:
137
+
138
+ ```bash
139
+ bundle install
140
+ ```
141
+
142
+ Run tests:
143
+
144
+ ```bash
145
+ ruby -Itest test/faq_json_ld_tag_test.rb
146
+ ruby -Itest test/schema_json_ld_tag_test.rb
147
+ ruby -Itest test/schema_page_and_project_test.rb
148
+ ruby -Itest test/faq_formats_test.rb
149
+ ```
@@ -0,0 +1,44 @@
1
+ {% assign faq_items = include.items | default: page.faq | default: site.data.settings.faq.faq_items %}
2
+ {% assign faq_title = include.title %}
3
+ {% assign faq_description = include.description %}
4
+ {% assign faq_enabled = include.enabled %}
5
+
6
+ {% if faq_enabled == nil %}
7
+ {% if include.items or page.faq %}
8
+ {% assign faq_enabled = true %}
9
+ {% else %}
10
+ {% assign faq_enabled = site.data.settings.faq.enable %}
11
+ {% endif %}
12
+ {% endif %}
13
+
14
+ {% if faq_enabled and faq_items and faq_items.size > 0 %}
15
+ <section class="section faq">
16
+ <div class="container">
17
+ <div class="row">
18
+ <div class="col col-12">
19
+ {% if faq_title or faq_description %}
20
+ <div class="section__info">
21
+ {% if faq_title %}
22
+ <h2 class="section__title">{{ faq_title }}</h2>
23
+ {% endif %}
24
+ {% if faq_description %}
25
+ <p class="section__description">{{ faq_description }}</p>
26
+ {% endif %}
27
+ </div>
28
+ {% endif %}
29
+
30
+ <div class="faq">
31
+ {% for faq_item in faq_items %}
32
+ <div class="faq__item" tabindex="0" data-name="closed">
33
+ <h3 class="faq__title">{{ faq_item.title | default: faq_item.question }}<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="var(--heading-font-color)" d="M8.654 1.168a1.153 1.153 0 1 0-2.308 0V6.36H1.154a1.153 1.153 0 1 0 0 2.308h5.192v5.192a1.153 1.153 0 1 0 2.308 0V8.668h5.192c.638 0 1.154-.516 1.154-1.154 0-.639-.516-1.154-1.154-1.154H8.654V1.168Z"/></svg></h3>
34
+ <div class="faq__description">
35
+ <p>{{ faq_item.description | default: faq_item.answer }}</p>
36
+ </div>
37
+ </div>
38
+ {% endfor %}
39
+ </div>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </section>
44
+ {% endif %}
@@ -0,0 +1,54 @@
1
+ require "json"
2
+
3
+ module Jekyll
4
+ module StructuredContent
5
+ class FaqJsonLdTag < Liquid::Tag
6
+ def render(context)
7
+ page = context.registers[:page] || {}
8
+ faq_items = page["faq"]
9
+
10
+ return "" unless faq_items.is_a?(Array) && faq_items.any?
11
+
12
+ entities = faq_items.filter_map do |item|
13
+ question = value_for(item, "question", "title")
14
+ answer = value_for(item, "answer", "description")
15
+ next unless question && answer
16
+
17
+ {
18
+ "@type" => "Question",
19
+ "name" => question,
20
+ "acceptedAnswer" => {
21
+ "@type" => "Answer",
22
+ "text" => normalize_text(answer)
23
+ }
24
+ }
25
+ end
26
+
27
+ return "" if entities.empty?
28
+
29
+ {
30
+ "@context" => "https://schema.org",
31
+ "@type" => "FAQPage",
32
+ "mainEntity" => entities
33
+ }.to_json
34
+ end
35
+
36
+ private
37
+
38
+ def value_for(item, *keys)
39
+ keys.each do |key|
40
+ value = item[key] || item[key.to_sym]
41
+ return value if value && !value.to_s.strip.empty?
42
+ end
43
+
44
+ nil
45
+ end
46
+
47
+ def normalize_text(text)
48
+ text.to_s.gsub(%r{<[^>]+>}, " ").gsub(/\s+/, " ").strip
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ Liquid::Template.register_tag("faq_json_ld", Jekyll::StructuredContent::FaqJsonLdTag)
@@ -0,0 +1,20 @@
1
+ module Jekyll
2
+ module StructuredContent
3
+ module SchemaFilters
4
+ def schema_type_for(input)
5
+ case input.to_s
6
+ when "post"
7
+ "BlogPosting"
8
+ when "page"
9
+ "WebPage"
10
+ when "project"
11
+ "CreativeWork"
12
+ else
13
+ "WebPage"
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ Liquid::Template.register_filter(Jekyll::StructuredContent::SchemaFilters)
@@ -0,0 +1,185 @@
1
+ require "json"
2
+
3
+ module Jekyll
4
+ module StructuredContent
5
+ class SchemaJsonLdTag < Liquid::Tag
6
+ def initialize(tag_name, markup, tokens)
7
+ super
8
+ @schema_kind = markup.to_s.strip
9
+ end
10
+
11
+ def render(context)
12
+ page = context.registers[:page] || {}
13
+ site = context.registers[:site]
14
+ data = build_schema(@schema_kind, page, site)
15
+
16
+ return "" unless data
17
+
18
+ data.to_json
19
+ end
20
+
21
+ private
22
+
23
+ def build_schema(kind, page, site)
24
+ case kind
25
+ when "post"
26
+ build_post_schema(page, site)
27
+ when "page"
28
+ build_page_schema(page, site)
29
+ when "project"
30
+ build_project_schema(page, site)
31
+ else
32
+ nil
33
+ end
34
+ end
35
+
36
+ def build_post_schema(page, site)
37
+ site_title = site_value(site, "data", "settings", "title")
38
+ author_name = site_value(site, "data", "settings", "author", "author_name") || site_title
39
+ description = page["description"] || page["excerpt"]
40
+ image = absolute_url(page["image"], site)
41
+ tags = page["tags"] || []
42
+
43
+ data = {
44
+ "@context" => "https://schema.org",
45
+ "@type" => "BlogPosting",
46
+ "headline" => page["title"],
47
+ "description" => normalize_text(description),
48
+ "datePublished" => xmlschema(page["date"]),
49
+ "mainEntityOfPage" => {
50
+ "@type" => "WebPage",
51
+ "@id" => absolute_url(page["url"], site)
52
+ },
53
+ "url" => absolute_url(page["url"], site),
54
+ "articleSection" => page["category"] || "Blog",
55
+ "keywords" => Array(tags).join(", "),
56
+ "wordCount" => word_count(page["content"]),
57
+ "isPartOf" => {
58
+ "@type" => "Blog",
59
+ "name" => site_title,
60
+ "url" => absolute_url("/blog/", site)
61
+ },
62
+ "about" => build_about_entities(page),
63
+ "author" => {
64
+ "@type" => "Person",
65
+ "name" => author_name
66
+ },
67
+ "publisher" => {
68
+ "@type" => "Organization",
69
+ "name" => site_title,
70
+ "logo" => {
71
+ "@type" => "ImageObject",
72
+ "url" => absolute_url(site_value(site, "data", "settings", "logo"), site)
73
+ }
74
+ }
75
+ }
76
+
77
+ date_modified = xmlschema(page["last_modified_at"])
78
+ data["dateModified"] = date_modified if present_value?(date_modified)
79
+ data["image"] = { "@type" => "ImageObject", "url" => image } if image
80
+ data
81
+ end
82
+
83
+ def build_page_schema(page, site)
84
+ site_title = site_value(site, "data", "settings", "title")
85
+ description = page["description"] || site_value(site, "data", "settings", "description")
86
+ image = absolute_url(page["image"], site)
87
+ page_type = page["url"] == "/about/" ? "ProfilePage" : "WebPage"
88
+
89
+ data = {
90
+ "@context" => "https://schema.org",
91
+ "@type" => page_type,
92
+ "name" => page["title"],
93
+ "description" => normalize_text(description),
94
+ "url" => absolute_url(page["url"], site)
95
+ }
96
+
97
+ data["image"] = { "@type" => "ImageObject", "url" => image } if image
98
+
99
+ if page["url"] == "/about/"
100
+ data["mainEntity"] = {
101
+ "@type" => "Person",
102
+ "name" => site_value(site, "data", "settings", "author", "author_name") || site_title,
103
+ "description" => normalize_text(description),
104
+ "url" => absolute_url(page["url"], site)
105
+ }
106
+ end
107
+
108
+ data
109
+ end
110
+
111
+ def build_project_schema(page, site)
112
+ data = {
113
+ "@context" => "https://schema.org",
114
+ "@type" => "CreativeWork",
115
+ "name" => page["title"],
116
+ "description" => normalize_text(page["description"] || page["excerpt"]),
117
+ "url" => absolute_url(page["url"], site)
118
+ }
119
+
120
+ image = absolute_url(page["image"], site)
121
+ data["image"] = image if image
122
+ data
123
+ end
124
+
125
+ def build_about_entities(page)
126
+ entities = []
127
+
128
+ entities << { "@type" => "Thing", "name" => page["category"] } if page["category"]
129
+ Array(page["tags"]).each do |tag|
130
+ entities << { "@type" => "Thing", "name" => tag }
131
+ end
132
+
133
+ entities
134
+ end
135
+
136
+ def absolute_url(path, site)
137
+ return nil unless path && site.respond_to?(:config)
138
+
139
+ Jekyll::URL.new(
140
+ template: ":path",
141
+ placeholders: { path: path.to_s }
142
+ ).to_s
143
+
144
+ base = site.config["url"].to_s.sub(%r{/$}, "")
145
+ prefix = site.config["baseurl"].to_s
146
+ normalized_path = path.to_s.start_with?("/") ? path.to_s : "/#{path}"
147
+ "#{base}#{prefix}#{normalized_path}"
148
+ end
149
+
150
+ def xmlschema(value)
151
+ return nil unless value.respond_to?(:xmlschema)
152
+
153
+ value.xmlschema
154
+ end
155
+
156
+ def present_value?(value)
157
+ !value.nil? && !(value.respond_to?(:empty?) && value.empty?)
158
+ end
159
+
160
+ def word_count(content)
161
+ normalize_text(content).split.size
162
+ end
163
+
164
+ def normalize_text(text)
165
+ text.to_s.gsub(%r{<[^>]+>}, " ").gsub(/\s+/, " ").strip
166
+ end
167
+
168
+ def site_value(site, *keys)
169
+ current = site
170
+ keys.each do |key|
171
+ current =
172
+ if current.respond_to?(:[])
173
+ current[key]
174
+ elsif current.respond_to?(key)
175
+ current.public_send(key)
176
+ end
177
+ return nil if current.nil?
178
+ end
179
+ current
180
+ end
181
+ end
182
+ end
183
+ end
184
+
185
+ Liquid::Template.register_tag("schema_json_ld", Jekyll::StructuredContent::SchemaJsonLdTag)
@@ -0,0 +1,5 @@
1
+ module Jekyll
2
+ module StructuredContent
3
+ VERSION = "0.2.0"
4
+ end
5
+ end
@@ -0,0 +1,9 @@
1
+ require_relative "structured_content/version"
2
+ require_relative "structured_content/faq_json_ld_tag"
3
+ require_relative "structured_content/schema_json_ld_tag"
4
+ require_relative "structured_content/schema_filters"
5
+
6
+ module Jekyll
7
+ module StructuredContent
8
+ end
9
+ end
@@ -0,0 +1,4 @@
1
+ require "jekyll"
2
+ require "liquid"
3
+
4
+ require_relative "jekyll/structured_content"
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jekyll-structured-content
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Jason Chance
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-08-07 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: jekyll
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '4.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '4.0'
26
+ description: A focused Jekyll plugin for FAQPage JSON-LD and post, page, and project
27
+ schema helpers.
28
+ email:
29
+ - jchance@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - CONTRIBUTING.md
36
+ - LICENSE.txt
37
+ - README.md
38
+ - _includes/jekyll_structured_content/faq.html
39
+ - lib/jekyll-structured-content.rb
40
+ - lib/jekyll/structured_content.rb
41
+ - lib/jekyll/structured_content/faq_json_ld_tag.rb
42
+ - lib/jekyll/structured_content/schema_filters.rb
43
+ - lib/jekyll/structured_content/schema_json_ld_tag.rb
44
+ - lib/jekyll/structured_content/version.rb
45
+ homepage: https://github.com/jchance/jekyll-structured-content
46
+ licenses:
47
+ - MIT
48
+ metadata:
49
+ homepage_uri: https://github.com/jchance/jekyll-structured-content
50
+ source_code_uri: https://github.com/jchance/jekyll-structured-content
51
+ changelog_uri: https://github.com/jchance/jekyll-structured-content/blob/main/CHANGELOG.md
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 3.6.2
67
+ specification_version: 4
68
+ summary: Structured data helpers for Jekyll content
69
+ test_files: []