jekyll-secret-posts 0.1.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: '032359338c8f0f94913d90be4ba0c17e1f86cff32c37636d7e493b77250ccc92'
4
+ data.tar.gz: a56e6bc96d5e924ed8897afe4f75b2dfb56f44a329bce40338f1a7844d1faa00
5
+ SHA512:
6
+ metadata.gz: 7fc592e410680cd2e0604245bcbcd7395c87486073e1c195fcd42f377c742aec3fae393e4f73d33c77a97e8e940496c7262e03e59bd0359fbb311a331ab72ee4
7
+ data.tar.gz: b0f75614598f7cca7ee4517f72288016e05c4b2d7e8fc4060cbd48432593a687af1c00a4fa1be842b05112e985d48ffe41dc1489b00b39117a8fc04c0ef93a54
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
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,151 @@
1
+ # Jekyll Secret Posts
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/jekyll-secret-posts.svg)](https://badge.fury.io/rb/jekyll-secret-posts)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ **Jekyll Secret Posts** is a lightweight, open-source [Jekyll](https://jekyllrb.com) plugin designed to publish "share-only" posts. It easily integrates with any Jekyll project and is compatible with other Jekyll plugins.
7
+
8
+ At build time, the plugin hashes the Markdown file path with SHA-256 to generate unique URLs. Since these hashed URLs are excluded from sitemaps and search engine indexing, your posts remain accessible only to those with the direct link.
9
+
10
+ By enabling link-only access, the plugin allows for exclusive content sharing and gives more privacy to your website.
11
+
12
+ <br>
13
+
14
+ * [Installation](#installation)
15
+ * [Getting Started](#getting-started)
16
+ * [Configuration](#configuration)
17
+ * [Compatibility](#compatibility)
18
+ * [Contributions](#contributions)
19
+
20
+ <br>
21
+
22
+ ## Installation
23
+
24
+ ### With Bundler
25
+
26
+ Add `jekyll-secret-posts` gem to your `Gemfile`:
27
+
28
+ ```ruby
29
+ gem "jekyll-secret-posts"
30
+ ```
31
+
32
+ or use GitHub repository link:
33
+
34
+ ```ruby
35
+ gem "jekyll-secret-posts", git: "https://github.com/developerlee79/jekyll-secret-posts.git"
36
+ ```
37
+
38
+ ### Manual
39
+
40
+ Or install the gem manually and specify the plugin in your `_config.yml`:
41
+
42
+ ```shell
43
+ gem install jekyll-secret-posts
44
+ ```
45
+
46
+ ```yaml
47
+ plugins:
48
+ - jekyll-secret-posts
49
+ ```
50
+
51
+ <br>
52
+
53
+ ## Getting Started
54
+
55
+ Create a `_secret` directory in your Jekyll project root and add markdown file(same as regular posts).
56
+
57
+ ```text
58
+ my-jekyll-site/
59
+ ├── _config.yml
60
+ ├── _layouts/
61
+ └── _secret/
62
+ └── article.md # Any .md files in this directory or its subfolders are supported
63
+ ```
64
+
65
+ Since the URL is hashed, you cannot know it without inspecting the built output. The easiest way to see it is in the build log when you build; this plugin only exposes those URLs in the log via the `list_urls` option.
66
+
67
+ However, enabling this in production or CI can expose hashed URLs on external servers or in pipeline logs. For that reason, `list_urls` defaults to `false` so you can turn it on manually only in safe environments.
68
+
69
+ For this guide, we enable it so you can verify that secret URLs are generated. Add the `list_urls` option to `_config.yml`:
70
+
71
+ ```yaml
72
+ secret_posts:
73
+ list_urls: true
74
+ ```
75
+
76
+ URLs are hashed using the `JEKYLL_SECRET_SALT` environment variable as salt. the plugin is able to hash without it, but in that case the URL can be guessed, so setting a salt is highly recommended for security.
77
+
78
+ Set it before build and build:
79
+
80
+ ```bash
81
+ export JEKYLL_SECRET_SALT="my-secret"
82
+ bundle exec jekyll build
83
+ ```
84
+
85
+ or run build with the environment variable:
86
+
87
+ ```bash
88
+ JEKYLL_SECRET_SALT="my-secret" bundle exec jekyll build
89
+ ```
90
+
91
+ Then you will be able to find the hashed URL in the build log like this:
92
+
93
+ ```log
94
+ Secret post URL: /s/eac1bc3d5e2cb1881215f42c7926d462/
95
+ AutoPages: Disabled/Not configured in site.config.
96
+ Pagination: Complete, processed 1 pagination page(s)
97
+ done in 1.825 seconds.
98
+ ```
99
+
100
+ All done! Share the URL only with people who should see the post.
101
+
102
+ <br>
103
+
104
+ ## Configuration
105
+
106
+ You can add custom settings to your `_config.yml` as follows:
107
+
108
+ ```yaml
109
+ secret_posts:
110
+ source_dir: "_secret"
111
+ collection_name: "secret"
112
+ url_prefix: "/s/"
113
+ index_layout: "default"
114
+ redirect_url: "/"
115
+ list_urls: false
116
+ ```
117
+
118
+ | Key | Default | Description |
119
+ |----------------------------|---------|-------------|
120
+ | `source_dir` | `"_secret"` | Directory containing target Markdown files |
121
+ | `collection_name` | `"secret"` | Internal Jekyll collection name |
122
+ | `url_prefix` | `"/s/"` | URL prefix for hashed URLs |
123
+ | `index_layout` | `"default"` | Layout used for the redirect page at the `url_prefix` |
124
+ | `redirect_url` | `baseurl` | URL to which the `url_prefix` index page redirects (If unset, the site `baseurl` is used; if that is also unset, `/` is used) |
125
+ | `list_urls` | `false` | When `true`, prints hashed URLs in Jekyll build log (Use only in safe environment) |
126
+
127
+ <br>
128
+
129
+ ## Compatibility
130
+
131
+ ### Jekyll Pagination
132
+
133
+ Secret posts live in a collection, not in `site.posts`, so they are not included in pagination.
134
+
135
+ ### Jekyll Sitemap
136
+
137
+ Secret documents are set to `sitemap: false`, so they do not appear in the sitemap.
138
+
139
+ ### Jekyll Polyglot
140
+
141
+ If you use Polyglot for localization and do not plan to support multiple languages for secret posts, add the secret source directory to `exclude_from_localization` in your `_config.yml` so that secret posts are not processed for multiple languages:
142
+
143
+ ```yaml
144
+ exclude_from_localization: ["images", "css", "scss", "js", "_secret"]
145
+ ```
146
+
147
+ <br>
148
+
149
+ ## Contributions
150
+
151
+ Contributions are welcome. If you have an improvement or idea, feel free to open a pull request.
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module SecretPosts
5
+ class Config
6
+ DEFAULT_SOURCE_DIR = "_secret"
7
+ DEFAULT_COLLECTION_NAME = "secret"
8
+ DEFAULT_URL_PREFIX = "/s/"
9
+ DEFAULT_INDEX_LAYOUT = "default"
10
+ TOKEN_LENGTH = 32
11
+
12
+ def initialize(site_config)
13
+ @site_config = site_config
14
+ @secret_posts = site_config["secret_posts"] || {}
15
+ end
16
+
17
+ def source_dir
18
+ @secret_posts["source_dir"] || DEFAULT_SOURCE_DIR
19
+ end
20
+
21
+ def collection_name
22
+ @secret_posts["collection_name"] || DEFAULT_COLLECTION_NAME
23
+ end
24
+
25
+ def url_prefix
26
+ prefix = @secret_posts["url_prefix"] || DEFAULT_URL_PREFIX
27
+ prefix = DEFAULT_URL_PREFIX if prefix.to_s.strip.empty?
28
+ prefix.end_with?("/") ? prefix : "#{prefix}/"
29
+ end
30
+
31
+ def salt
32
+ ENV["JEKYLL_SECRET_SALT"].to_s
33
+ end
34
+
35
+ def secret_index_layout
36
+ return DEFAULT_INDEX_LAYOUT unless @secret_posts.key?("index_layout")
37
+
38
+ layout = @secret_posts["index_layout"]
39
+ return nil if layout.nil? || layout == false
40
+
41
+ layout.to_s.empty? ? DEFAULT_INDEX_LAYOUT : layout.to_s
42
+ end
43
+
44
+ def redirect_url
45
+ custom = @secret_posts["redirect_url"].to_s.strip
46
+ return custom.end_with?("/") ? custom : "#{custom}/" unless custom.empty?
47
+
48
+ base = @site_config["baseurl"].to_s.strip
49
+ if base.empty?
50
+ "/"
51
+ else
52
+ base.end_with?("/") ? base : "#{base}/"
53
+ end
54
+ end
55
+
56
+ def token_length
57
+ TOKEN_LENGTH
58
+ end
59
+
60
+ def list_urls?
61
+ value = @secret_posts["list_urls"]
62
+ !value.nil? && value != false && value.to_s.strip != ""
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module SecretPosts
5
+ class Generator < Jekyll::Generator
6
+ safe true
7
+ priority :high
8
+
9
+ def generate(site)
10
+ config = Config.new(site.config)
11
+ add_secret_index_page(site, config)
12
+ log_secret_urls(site, config) if config.list_urls?
13
+ end
14
+
15
+ private
16
+
17
+ def add_secret_index_page(site, config)
18
+ prefix_dir = config.url_prefix.gsub(%r{\A/|/\z}, "")
19
+ page = Jekyll::PageWithoutAFile.new(site, site.source, prefix_dir, "index.html")
20
+ page.data["permalink"] = config.url_prefix
21
+ page.data["sitemap"] = false
22
+ assign_redirect_content(page, config)
23
+ site.pages << page
24
+ end
25
+
26
+ def assign_redirect_content(page, config)
27
+ url = config.redirect_url
28
+ if config.secret_index_layout
29
+ page.data["layout"] = config.secret_index_layout
30
+ page.content = redirect_fragment(url)
31
+ else
32
+ page.data["layout"] = nil
33
+ page.content = redirect_standalone_html(url)
34
+ end
35
+ end
36
+
37
+ def redirect_fragment(url)
38
+ <<~FRAGMENT.strip
39
+ <meta http-equiv="refresh" content="0;url=#{url}">
40
+ <p>Redirecting...</p>
41
+ <p><a href="#{url}">Go to homepage</a></p>
42
+ FRAGMENT
43
+ end
44
+
45
+ def redirect_standalone_html(url)
46
+ <<~HTML.strip
47
+ <!DOCTYPE html>
48
+ <html>
49
+ <head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=#{url}"></head>
50
+ <body><p>Redirecting...</p><p><a href="#{url}">Go to homepage</a></p></body>
51
+ </html>
52
+ HTML
53
+ end
54
+
55
+ def log_secret_urls(site, config)
56
+ collection = site.collections[config.collection_name]
57
+ unless collection
58
+ Jekyll.logger.info "Secret posts: no collection '#{config.collection_name}'"
59
+ return
60
+ end
61
+ ensure_collection_read(collection)
62
+ docs = collection.docs
63
+ if docs.empty?
64
+ Jekyll.logger.info "Secret posts: no documents"
65
+ return
66
+ end
67
+ log_secret_doc_urls(docs, config, site)
68
+ end
69
+
70
+ def log_secret_doc_urls(docs, config, site)
71
+ tokenizer = UrlTokenizer.new(config)
72
+ baseurl = normalized_baseurl(site.config["baseurl"])
73
+ docs.each { |doc| log_one_secret_url(doc, config, tokenizer, baseurl) }
74
+ end
75
+
76
+ def normalized_baseurl(baseurl_value)
77
+ baseurl = baseurl_value.to_s.strip
78
+ baseurl.empty? ? nil : baseurl
79
+ end
80
+
81
+ def log_one_secret_url(doc, config, tokenizer, baseurl)
82
+ token = tokenizer.token_for(doc.collection.label, doc.relative_path)
83
+ path = "#{config.url_prefix}#{token}/"
84
+ full_url = baseurl ? "#{baseurl.sub(%r{/\z}, '')}#{path}" : path
85
+ Jekyll.logger.info "Secret post URL: #{full_url}"
86
+ end
87
+
88
+ def ensure_collection_read(collection)
89
+ return unless collection.docs.empty? && collection.respond_to?(:read)
90
+
91
+ coll_dir = collection.respond_to?(:directory) ? collection.directory.to_s : ""
92
+ collection.read if !coll_dir.empty? && File.directory?(coll_dir)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll/secret_posts/config"
4
+ require "jekyll/secret_posts/url_tokenizer"
5
+
6
+ module Jekyll
7
+ module SecretPosts
8
+ module Hooks
9
+ NOINDEX_META = '<meta name="robots" content="noindex, nofollow">'
10
+
11
+ def self.register
12
+ Jekyll::Hooks.register(:site, :after_init) do |site|
13
+ register_secret_collection(site)
14
+ end
15
+ Jekyll::Hooks.register(:documents, :post_init) do |doc|
16
+ apply_secret_permalink(doc)
17
+ end
18
+ Jekyll::Hooks.register(:documents, :post_render) do |doc|
19
+ inject_noindex(doc)
20
+ end
21
+ end
22
+
23
+ def self.register_secret_collection(site)
24
+ config = Config.new(site.config)
25
+ collections = site.config["collections"] ||= {}
26
+ return if collections.key?(config.collection_name)
27
+
28
+ collections[config.collection_name] = {
29
+ "output" => true,
30
+ "source" => config.source_dir
31
+ }
32
+ exclude = site.config["exclude"]
33
+ exclude.reject! { |e| e.to_s == config.source_dir } if exclude.is_a?(Array)
34
+ end
35
+
36
+ def self.apply_secret_permalink(doc)
37
+ return unless secret_document?(doc)
38
+
39
+ config = Config.new(doc.site.config)
40
+ tokenizer = UrlTokenizer.new(config)
41
+ token = tokenizer.token_for(doc.collection.label, doc.relative_path)
42
+ doc.data["permalink"] = "#{config.url_prefix}#{token}/"
43
+ doc.data["sitemap"] = false
44
+ end
45
+
46
+ def self.secret_document?(doc)
47
+ doc.collection && doc.site &&
48
+ doc.collection.label == Config.new(doc.site.config).collection_name
49
+ end
50
+
51
+ def self.inject_noindex(doc)
52
+ return unless doc.collection && doc.site
53
+
54
+ config = Config.new(doc.site.config)
55
+ return unless doc.collection.label == config.collection_name
56
+ return unless doc.output
57
+
58
+ new_output = if doc.output.include?("<head>")
59
+ doc.output.sub("<head>", "<head>\n #{NOINDEX_META}")
60
+ else
61
+ "#{NOINDEX_META}\n#{doc.output}"
62
+ end
63
+ doc.output = new_output
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Jekyll
6
+ module SecretPosts
7
+ class UrlTokenizer
8
+ def initialize(config)
9
+ @config = config
10
+ end
11
+
12
+ def token_for(collection_label, relative_path)
13
+ identifier = "#{collection_label}#{relative_path}"
14
+ raw = Digest::SHA256.hexdigest(@config.salt + identifier)
15
+ raw[0, @config.token_length]
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll"
4
+ require "jekyll/secret_posts/config"
5
+ require "jekyll/secret_posts/url_tokenizer"
6
+ require "jekyll/secret_posts/generator"
7
+ require "jekyll/secret_posts/hooks"
8
+
9
+ module Jekyll
10
+ module SecretPosts
11
+ end
12
+ end
13
+
14
+ Jekyll::SecretPosts::Hooks.register
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll/secret_posts"
metadata ADDED
@@ -0,0 +1,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jekyll-secret-posts
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - devl79
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-02-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: jekyll
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '4.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '4.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.0'
55
+ description:
56
+ email:
57
+ - dltjgus0709@gmail.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - LICENSE
63
+ - README.md
64
+ - lib/jekyll-secret-posts.rb
65
+ - lib/jekyll/secret_posts.rb
66
+ - lib/jekyll/secret_posts/config.rb
67
+ - lib/jekyll/secret_posts/generator.rb
68
+ - lib/jekyll/secret_posts/hooks.rb
69
+ - lib/jekyll/secret_posts/url_tokenizer.rb
70
+ homepage: https://github.com/developerlee79/jekyll-secret-posts
71
+ licenses:
72
+ - MIT
73
+ metadata:
74
+ homepage_uri: https://github.com/developerlee79/jekyll-secret-posts
75
+ source_code_uri: https://github.com/developerlee79/jekyll-secret-posts
76
+ post_install_message:
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: 2.7.0
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubygems_version: 3.1.6
92
+ signing_key:
93
+ specification_version: 4
94
+ summary: Jekyll plugin for unlisted posts served at hashed, share-only URLs.
95
+ test_files: []