jekyll-client-search 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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +121 -0
  3. data/LICENSE +22 -0
  4. data/NOTICE +49 -0
  5. data/README.developer.md +204 -0
  6. data/README.md +948 -0
  7. data/assets/adapters/elasticlunr.js +59 -0
  8. data/assets/adapters/minisearch.js +57 -0
  9. data/assets/adapters/semantic.js +154 -0
  10. data/assets/client-search-base.js +294 -0
  11. data/assets/client-search-related.js +176 -0
  12. data/assets/includes/related-articles.html +36 -0
  13. data/assets/layouts/post-with-related.html +54 -0
  14. data/assets/query-embedders/ollama-api.js +63 -0
  15. data/assets/query-embedders/transformers-worker.js +130 -0
  16. data/assets/query-embedders/transformers.js +223 -0
  17. data/docs/assets/icon-256.png +0 -0
  18. data/docs/assets/icon.svg +133 -0
  19. data/lib/jekyll/client_search/configuration.rb +152 -0
  20. data/lib/jekyll/client_search/configuration_accessors.rb +48 -0
  21. data/lib/jekyll/client_search/document_builder.rb +66 -0
  22. data/lib/jekyll/client_search/embedder_config_page.rb +14 -0
  23. data/lib/jekyll/client_search/embedding_configuration.rb +95 -0
  24. data/lib/jekyll/client_search/generator.rb +134 -0
  25. data/lib/jekyll/client_search/index_cache.rb +82 -0
  26. data/lib/jekyll/client_search/live_search_configuration.rb +70 -0
  27. data/lib/jekyll/client_search/ollama_embedding_adapter.rb +59 -0
  28. data/lib/jekyll/client_search/query_embedder_configuration.rb +127 -0
  29. data/lib/jekyll/client_search/related_analyzer.rb +152 -0
  30. data/lib/jekyll/client_search/related_configuration.rb +103 -0
  31. data/lib/jekyll/client_search/related_page.rb +14 -0
  32. data/lib/jekyll/client_search/related_tag.rb +76 -0
  33. data/lib/jekyll/client_search/runtime_config_page.rb +30 -0
  34. data/lib/jekyll/client_search/search_index_page.rb +15 -0
  35. data/lib/jekyll/client_search/search_tag.rb +100 -0
  36. data/lib/jekyll/client_search/tasks.rb +137 -0
  37. data/lib/jekyll/client_search/version.rb +7 -0
  38. data/lib/jekyll/client_search.rb +25 -0
  39. data/lib/jekyll-client-search.rb +3 -0
  40. metadata +104 -0
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "fileutils"
6
+
7
+ module Jekyll
8
+ module ClientSearch
9
+ # Persists content hashes and cached embeddings across Jekyll builds so
10
+ # that unchanged documents are not re-embedded. The cache file lives in
11
+ # the site source directory as +.jekyll-client-search-cache.json+ and
12
+ # should be git-ignored.
13
+ class IndexCache
14
+ CACHE_FILE = ".jekyll-client-search-cache.json"
15
+
16
+ attr_reader :path
17
+
18
+ def initialize(site_source, embedding_identity: nil)
19
+ @path = File.join(site_source, CACHE_FILE)
20
+ @embedding_identity = embedding_identity
21
+ @entries = load
22
+ end
23
+
24
+ # Returns the cached entry for +id+ if the content hash and embedding
25
+ # identity match, nil otherwise (or if not cached).
26
+ def lookup(id, content_hash)
27
+ entry = @entries[id]
28
+ return nil unless entry
29
+ return nil unless entry["content_hash"] == content_hash
30
+ return nil if @embedding_identity && entry["embedding_identity"] != @embedding_identity
31
+
32
+ entry
33
+ end
34
+
35
+ # Stores or updates a cache entry for +id+.
36
+ def store(id, content_hash, embedding = nil)
37
+ @entries[id] = {
38
+ "content_hash" => content_hash,
39
+ "embedding" => embedding,
40
+ "embedding_identity" => @embedding_identity
41
+ }.compact
42
+ end
43
+
44
+ # Removes entries for IDs that are no longer present.
45
+ def prune(known_ids)
46
+ @entries.select! { |id, _| known_ids.include?(id) }
47
+ end
48
+
49
+ # Writes the cache to disk if any entries have changed.
50
+ def save
51
+ return unless dirty?
52
+
53
+ temporary_path = "#{@path}.tmp.#{Process.pid}.#{Thread.current.object_id}"
54
+ File.write(temporary_path, JSON.pretty_generate(@entries))
55
+ File.rename(temporary_path, @path)
56
+ ensure
57
+ FileUtils.rm_f(temporary_path) if temporary_path && File.exist?(temporary_path)
58
+ end
59
+
60
+ def self.content_hash(document)
61
+ Digest::SHA256.hexdigest(document.to_json)
62
+ end
63
+
64
+ private
65
+
66
+ def load
67
+ return {} unless File.exist?(@path)
68
+
69
+ data = JSON.parse(File.read(@path))
70
+ data.is_a?(Hash) ? data : {}
71
+ rescue JSON::ParserError
72
+ {}
73
+ end
74
+
75
+ def dirty?
76
+ return true unless File.exist?(@path)
77
+
78
+ File.read(@path) != JSON.pretty_generate(@entries)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ClientSearch
5
+ # Normalizes optional browser live-search behavior.
6
+ class LiveSearchConfiguration
7
+ DEFAULTS = {
8
+ "min_chars" => 2,
9
+ "debounce_ms" => 150,
10
+ "semantic_debounce_ms" => 500,
11
+ "update_url" => true
12
+ }.freeze
13
+
14
+ def initialize(config, engine: "minisearch")
15
+ unless config.is_a?(Hash)
16
+ raise Jekyll::Errors::FatalException,
17
+ "client_search live_search configuration must be a mapping"
18
+ end
19
+
20
+ @engine = engine
21
+ @values = DEFAULTS.merge(config)
22
+ @values["enabled"] = default_enabled?(engine) unless config.key?("enabled")
23
+ validate!
24
+ end
25
+
26
+ def to_h(engine:)
27
+ {
28
+ "enabled" => @values.fetch("enabled"),
29
+ "minChars" => @values.fetch("min_chars"),
30
+ "debounceMs" => debounce_ms(engine),
31
+ "updateUrl" => @values.fetch("update_url")
32
+ }
33
+ end
34
+
35
+ private
36
+
37
+ def default_enabled?(engine)
38
+ engine != "semantic"
39
+ end
40
+
41
+ def debounce_ms(engine)
42
+ key = engine == "semantic" ? "semantic_debounce_ms" : "debounce_ms"
43
+ @values.fetch(key)
44
+ end
45
+
46
+ def validate!
47
+ validate_boolean!("enabled")
48
+ validate_boolean!("update_url")
49
+ validate_integer!("min_chars", minimum: 0)
50
+ validate_integer!("debounce_ms", minimum: 0)
51
+ validate_integer!("semantic_debounce_ms", minimum: 0)
52
+ end
53
+
54
+ def validate_boolean!(key)
55
+ return if [true, false].include?(@values[key])
56
+
57
+ raise Jekyll::Errors::FatalException,
58
+ "client_search live_search.#{key} must be true or false"
59
+ end
60
+
61
+ def validate_integer!(key, minimum:)
62
+ value = @values.fetch(key)
63
+ return if value.is_a?(Integer) && value >= minimum
64
+
65
+ raise Jekyll::Errors::FatalException,
66
+ "client_search live_search.#{key} must be an integer greater than or equal to #{minimum}"
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ClientSearch
5
+ # Generates embeddings via a local Ollama server using the +ollama-ruby+
6
+ # gem. The gem is required lazily so that users who do not enable
7
+ # embeddings never need to install it.
8
+ #
9
+ # Configuration:
10
+ # embedding:
11
+ # enabled: true
12
+ # model: all-minilm # or nomic-embed-text, bge-m3, etc.
13
+ # base_url: http://localhost:11434
14
+ class OllamaEmbeddingAdapter
15
+ attr_reader :model, :base_url, :connect_timeout, :read_timeout
16
+
17
+ def initialize(model:, base_url: "http://localhost:11434", connect_timeout: 5, read_timeout: 120)
18
+ @model = model
19
+ @base_url = base_url
20
+ @connect_timeout = connect_timeout
21
+ @read_timeout = read_timeout
22
+ end
23
+
24
+ # Returns a float vector for the given text. Raises a clear error if
25
+ # the ollama-ruby gem is not installed or the server is unreachable.
26
+ def embed(text)
27
+ embedding = client.embed(model: @model, input: text).embeddings&.first
28
+ return embedding if valid_embedding?(embedding)
29
+
30
+ Jekyll.logger.warn "ClientSearch:", "embedding response was empty or invalid"
31
+ nil
32
+ rescue LoadError, NameError
33
+ raise Jekyll::Errors::FatalException,
34
+ "Add gem \"ollama-ruby\" to your Gemfile to use embedding features"
35
+ rescue StandardError => e
36
+ Jekyll.logger.warn "ClientSearch:", "embedding failed for text: #{e.message}"
37
+ nil
38
+ end
39
+
40
+ private
41
+
42
+ def valid_embedding?(embedding)
43
+ embedding.is_a?(Array) && !embedding.empty? &&
44
+ embedding.all? { |value| value.is_a?(Numeric) && value.finite? }
45
+ end
46
+
47
+ def client
48
+ @client ||= begin
49
+ require "ollama"
50
+ Ollama::Client.new(
51
+ base_url: @base_url,
52
+ connect_timeout: @connect_timeout,
53
+ read_timeout: @read_timeout
54
+ )
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ClientSearch
5
+ # Normalizes browser query-embedder settings.
6
+ class QueryEmbedderConfiguration
7
+ TYPES = %w[transformers ollama_api none].freeze
8
+
9
+ MODEL_MAP = {
10
+ "embeddinggemma:300m" => "onnx-community/embeddinggemma-300m-ONNX",
11
+ "all-minilm" => "Xenova/all-MiniLM-L6-v2"
12
+ }.freeze
13
+
14
+ DEFAULT_LIBRARY_URL = "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1"
15
+
16
+ DEFAULTS = {
17
+ "type" => "transformers",
18
+ "library_url" => DEFAULT_LIBRARY_URL,
19
+ "dtype" => "q8",
20
+ "worker" => true,
21
+ "timeout_ms" => 300_000,
22
+ "retry_attempts" => 1,
23
+ "max_tokens" => 512
24
+ }.freeze
25
+
26
+ JSON_KEYS = {
27
+ "library_url" => "libraryUrl",
28
+ "model_base_url" => "modelBaseUrl",
29
+ "wasm_base_url" => "wasmBaseUrl",
30
+ "worker_url" => "workerUrl",
31
+ "device" => "device",
32
+ "dtype" => "dtype",
33
+ "worker" => "worker",
34
+ "timeout_ms" => "timeoutMs",
35
+ "retry_attempts" => "retryAttempts",
36
+ "max_tokens" => "maxTokens"
37
+ }.freeze
38
+
39
+ def initialize(config, build_model:, build_base_url:, query_prefix:)
40
+ unless config.is_a?(Hash)
41
+ raise Jekyll::Errors::FatalException,
42
+ "client_search embedding.query_embedder configuration must be a mapping"
43
+ end
44
+
45
+ @values = DEFAULTS.merge(config)
46
+ @values["timeout_ms"] = 30_000 if @values["type"] == "ollama_api" && !config.key?("timeout_ms")
47
+ @build_model = build_model
48
+ @build_base_url = build_base_url
49
+ @query_prefix = query_prefix
50
+ validate!
51
+ end
52
+
53
+ def type
54
+ @values.fetch("type")
55
+ end
56
+
57
+ def model
58
+ return @values.fetch("model") if @values.key?("model")
59
+ return @build_model unless type == "transformers"
60
+ return MODEL_MAP.fetch(@build_model) if MODEL_MAP.key?(@build_model)
61
+
62
+ raise Jekyll::Errors::FatalException,
63
+ "embedding query_embedder.model is required for #{@build_model.inspect}"
64
+ end
65
+
66
+ def api_url
67
+ @values.fetch("api_url", "#{@build_base_url}/api/embed")
68
+ end
69
+
70
+ def assets
71
+ case type
72
+ when "transformers"
73
+ files = ["assets/query-embedders/transformers.js"]
74
+ files << "assets/query-embedders/transformers-worker.js" if @values["worker"]
75
+ files
76
+ when "ollama_api"
77
+ ["assets/query-embedders/ollama-api.js"]
78
+ else
79
+ []
80
+ end
81
+ end
82
+
83
+ def asset
84
+ assets.first
85
+ end
86
+
87
+ def to_json(*)
88
+ config = {
89
+ "type" => type,
90
+ "model" => model,
91
+ "apiUrl" => api_url,
92
+ "buildModel" => @build_model,
93
+ "queryPrefix" => @query_prefix
94
+ }
95
+ JSON_KEYS.each do |source, target|
96
+ config[target] = @values[source] if @values.key?(source)
97
+ end
98
+ config.to_json
99
+ end
100
+
101
+ private
102
+
103
+ def validate!
104
+ unless TYPES.include?(type)
105
+ valid = TYPES.join(", ")
106
+ raise Jekyll::Errors::FatalException,
107
+ "embedding query_embedder type must be one of #{valid} (got #{type.inspect})"
108
+ end
109
+ validate_integer!("timeout_ms", minimum: 1)
110
+ validate_integer!("retry_attempts", minimum: 0)
111
+ validate_integer!("max_tokens", minimum: 1)
112
+ return if [true, false].include?(@values["worker"])
113
+
114
+ raise Jekyll::Errors::FatalException,
115
+ "embedding query_embedder.worker must be true or false"
116
+ end
117
+
118
+ def validate_integer!(key, minimum:)
119
+ value = @values.fetch(key)
120
+ return if value.is_a?(Integer) && value >= minimum
121
+
122
+ raise Jekyll::Errors::FatalException,
123
+ "embedding query_embedder.#{key} must be an integer greater than or equal to #{minimum}"
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ClientSearch
5
+ # Builds deterministic related-article records from metadata and vectors.
6
+ class RelatedAnalyzer
7
+ def initialize(configuration)
8
+ @configuration = configuration
9
+ end
10
+
11
+ def analyze(documents)
12
+ relations = documents.each_with_index.to_h do |source, index|
13
+ candidates = documents.each_with_index.filter_map do |target, target_index|
14
+ next if index == target_index
15
+
16
+ build_relation(source, target)
17
+ end
18
+ [source.fetch("id"), sort_relations(candidates)]
19
+ end
20
+
21
+ {
22
+ "version" => 1,
23
+ "minimum_similarity" => @configuration.minimum_similarity,
24
+ "relations" => relations
25
+ }
26
+ end
27
+
28
+ private
29
+
30
+ def build_relation(source, target)
31
+ metadata = metadata_relation(source, target)
32
+ semantic_similarity = similarity(source["embedding"], target["embedding"])
33
+ return unless metadata || semantic_match?(semantic_similarity)
34
+
35
+ relation = base_relation(target, metadata, semantic_similarity)
36
+ add_semantic_relation(relation, semantic_similarity) if semantic_match?(semantic_similarity)
37
+ add_metadata_relation(relation, metadata)
38
+ relation
39
+ end
40
+
41
+ def semantic_match?(similarity)
42
+ @configuration.semantic? && similarity && similarity >= @configuration.minimum_similarity
43
+ end
44
+
45
+ def base_relation(target, metadata, semantic_similarity)
46
+ metadata_score = metadata ? metadata.fetch(:score) : 0.0
47
+ score = [metadata_score, semantic_similarity || 0.0].max
48
+ reasons = metadata ? metadata.fetch(:reasons).dup : []
49
+ reasons << "semantic-similarity" if semantic_match?(semantic_similarity)
50
+ {
51
+ "id" => target.fetch("id"),
52
+ "title" => target.fetch("title"),
53
+ "url" => target.fetch("url"),
54
+ "date" => target["date"],
55
+ "date_timestamp" => target["date_timestamp"],
56
+ "score" => score.round(6),
57
+ "reasons" => reasons.uniq.sort
58
+ }.compact
59
+ end
60
+
61
+ def add_semantic_relation(relation, similarity)
62
+ relation["semantic_similarity"] = similarity.round(6)
63
+ end
64
+
65
+ def add_metadata_relation(relation, metadata)
66
+ return unless metadata
67
+
68
+ relation["shared_tags"] = metadata.fetch(:tags) if metadata.fetch(:tags).any?
69
+ relation["shared_categories"] = metadata.fetch(:categories) if metadata.fetch(:categories).any?
70
+ relation["shared_domains"] = metadata.fetch(:domains) if metadata.fetch(:domains).any?
71
+ end
72
+
73
+ def metadata_relation(source, target)
74
+ shared_tags = configured_intersection(source["tags"], target["tags"], @configuration.shared_tags?)
75
+ shared_categories = configured_intersection(
76
+ source["categories"], target["categories"], @configuration.same_category?
77
+ )
78
+ shared_domains = configured_intersection(
79
+ domain_paths(source), domain_paths(target), @configuration.include_parent_domains?
80
+ ) - shared_categories
81
+ return if shared_tags.empty? && shared_categories.empty? && shared_domains.empty?
82
+
83
+ {
84
+ score: metadata_score(source, target, shared_tags, shared_categories, shared_domains),
85
+ tags: shared_tags,
86
+ categories: shared_categories,
87
+ domains: shared_domains,
88
+ reasons: metadata_reasons(shared_tags, shared_categories, shared_domains)
89
+ }
90
+ end
91
+
92
+ def configured_intersection(first, second, enabled)
93
+ enabled ? intersection(first, second) : []
94
+ end
95
+
96
+ def metadata_score(source, target, tags, categories, domains)
97
+ tag_score = tags.empty? ? 0.0 : 0.25 * tags.length.to_f / union(source["tags"], target["tags"]).length
98
+ [tag_score + (categories.any? ? 0.5 : 0.0) + (domains.any? ? 0.25 : 0.0), 0.01].max
99
+ end
100
+
101
+ def metadata_reasons(tags, categories, domains)
102
+ tags.map { |tag| "shared-tag: #{tag}" } +
103
+ categories.map { |category| "shared-category: #{category}" } +
104
+ domains.map { |domain| "shared-domain: #{domain}" }
105
+ end
106
+
107
+ def sort_relations(relations)
108
+ sorted = relations.sort_by do |relation|
109
+ [-relation.fetch("score"), relation.fetch("title"), relation.fetch("id")]
110
+ end
111
+ maximum = @configuration.max_items
112
+ maximum ? sorted.first(maximum) : sorted
113
+ end
114
+
115
+ def similarity(first, second)
116
+ return unless valid_vector?(first)
117
+ return unless valid_vector?(second)
118
+ return unless first.length == second.length
119
+
120
+ dot = first.zip(second).sum { |a, b| a * b }
121
+ denominator = vector_norm(first) * vector_norm(second)
122
+ denominator.zero? ? 0.0 : dot / denominator
123
+ end
124
+
125
+ def vector_norm(vector)
126
+ Math.sqrt(vector.sum { |value| value * value })
127
+ end
128
+
129
+ def valid_vector?(vector)
130
+ return false unless vector.is_a?(Array)
131
+ return false if vector.empty?
132
+
133
+ vector.all? { |value| value.is_a?(Numeric) && value.finite? }
134
+ end
135
+
136
+ def intersection(first, second)
137
+ (Array(first).map(&:to_s) & Array(second).map(&:to_s)).sort
138
+ end
139
+
140
+ def union(first, second)
141
+ (Array(first).map(&:to_s) | Array(second).map(&:to_s))
142
+ end
143
+
144
+ def domain_paths(document)
145
+ Array(document["categories"]).flat_map do |category|
146
+ parts = category.to_s.split("/").reject(&:empty?)
147
+ parts.each_index.map { |index| parts.first(index + 1).join("/") }
148
+ end.uniq
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ClientSearch
5
+ # Validates related-article output and matching rules.
6
+ class RelatedConfiguration
7
+ DEFAULTS = {
8
+ "enabled" => false,
9
+ "output" => "search-relations.json",
10
+ "same_category" => true,
11
+ "shared_tags" => true,
12
+ "include_parent_domains" => true,
13
+ "semantic" => true,
14
+ "minimum_similarity" => 0.55,
15
+ "max_items" => nil
16
+ }.freeze
17
+
18
+ def initialize(config)
19
+ unless config.is_a?(Hash)
20
+ raise Jekyll::Errors::FatalException,
21
+ "client_search related configuration must be a mapping"
22
+ end
23
+
24
+ @values = DEFAULTS.merge(config)
25
+ validate!
26
+ end
27
+
28
+ def enabled?
29
+ @values.fetch("enabled") == true
30
+ end
31
+
32
+ def output
33
+ value = @values.fetch("output").to_s.sub(%r{\A/+}, "")
34
+ normalized = Pathname.new(value).cleanpath.to_s
35
+ return normalized unless value.empty? || normalized == "." || normalized.start_with?("../")
36
+
37
+ raise Jekyll::Errors::FatalException,
38
+ "client_search related output must be a relative file path inside the destination"
39
+ end
40
+
41
+ def same_category?
42
+ @values.fetch("same_category") == true
43
+ end
44
+
45
+ def shared_tags?
46
+ @values.fetch("shared_tags") == true
47
+ end
48
+
49
+ def include_parent_domains?
50
+ @values.fetch("include_parent_domains") == true
51
+ end
52
+
53
+ def semantic?
54
+ @values.fetch("semantic") == true
55
+ end
56
+
57
+ def minimum_similarity
58
+ @values.fetch("minimum_similarity").to_f
59
+ end
60
+
61
+ def max_items
62
+ @values.fetch("max_items")
63
+ end
64
+
65
+ private
66
+
67
+ def validate!
68
+ validate_booleans!
69
+ validate_similarity!
70
+ validate_max_items!
71
+ end
72
+
73
+ def validate_booleans!
74
+ %w[enabled same_category shared_tags include_parent_domains semantic].each do |key|
75
+ validate_boolean!(key)
76
+ end
77
+ end
78
+
79
+ def validate_similarity!
80
+ similarity = @values.fetch("minimum_similarity")
81
+ return if similarity.is_a?(Numeric) && similarity.finite? && similarity.between?(-1, 1)
82
+
83
+ raise Jekyll::Errors::FatalException,
84
+ "client_search related minimum_similarity must be finite and between -1 and 1"
85
+ end
86
+
87
+ def validate_max_items!
88
+ maximum = max_items
89
+ return if maximum.nil? || (maximum.is_a?(Integer) && maximum.positive?)
90
+
91
+ raise Jekyll::Errors::FatalException,
92
+ "client_search related max_items must be a positive integer or null"
93
+ end
94
+
95
+ def validate_boolean!(key)
96
+ return if [true, false].include?(@values[key])
97
+
98
+ raise Jekyll::Errors::FatalException,
99
+ "client_search related #{key} must be true or false"
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ClientSearch
5
+ # Writes build-time related-article data as a standalone JSON page.
6
+ class RelatedPage < Jekyll::PageWithoutAFile
7
+ def initialize(site, output, relation_data)
8
+ super(site, site.source, File.dirname(output), File.basename(output))
9
+ self.data = { "layout" => nil, "sitemap" => false }
10
+ self.content = JSON.generate(relation_data)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ClientSearch
5
+ # Liquid tag that renders the related-articles container, sort control,
6
+ # and runtime scripts in one line. Drop into any post layout:
7
+ #
8
+ # {% related_articles %}
9
+ # {% related_articles sort:date %}
10
+ # {% related_articles no_scripts %}
11
+ #
12
+ # When +related.enabled+ is false the tag renders nothing, so it is safe
13
+ # to leave in a layout even when the feature is off.
14
+ class RelatedTag < Liquid::Tag
15
+ SYNTAX = /\A\s*(sort:(\w+))?\s*(no_scripts)?\s*\z/
16
+
17
+ def initialize(tag_name, markup, tokens)
18
+ super
19
+ @markup = markup.to_s
20
+ unless (match = @markup.match(SYNTAX))
21
+ raise Liquid::SyntaxError,
22
+ "related_articles: invalid syntax. Use {% related_articles %}, " \
23
+ "{% related_articles sort:date %}, or {% related_articles no_scripts %}"
24
+ end
25
+
26
+ @sort = match[2] if match[2]
27
+ @include_scripts = match[3].nil?
28
+ end
29
+
30
+ def render(context)
31
+ site = context.registers[:site]
32
+ config = site&.config&.fetch("client_search", {})
33
+ return "" unless related_enabled?(config)
34
+
35
+ asset_prefix = asset_prefix(site)
36
+ sort_attr = @sort ? " data-related-sort=\"#{@sort}\"" : ""
37
+ scripts = build_scripts(asset_prefix)
38
+ build_html(sort_attr, scripts)
39
+ end
40
+
41
+ private
42
+
43
+ def related_enabled?(config)
44
+ related = config.fetch("related", {})
45
+ related["enabled"] == true
46
+ end
47
+
48
+ def asset_prefix(site)
49
+ baseurl = site.config["baseurl"].to_s.gsub(%r{\A/+|/+$}, "")
50
+ baseurl.empty? ? "" : "/#{baseurl}"
51
+ end
52
+
53
+ def build_scripts(prefix)
54
+ return "" unless @include_scripts
55
+
56
+ "\n<script src=\"#{prefix}/assets/search-runtime-config.js\"></script>\n" \
57
+ "<script src=\"#{prefix}/assets/client-search-related.js\"></script>"
58
+ end
59
+
60
+ def build_html(sort_attr, scripts)
61
+ <<~HTML
62
+ <section class="related-articles-section">
63
+ <label for="related-sort">Sort related articles</label>
64
+ <select id="related-sort">
65
+ <option value="relevance">Most related</option>
66
+ <option value="date">Newest</option>
67
+ </select>
68
+ <div id="related-articles"#{sort_attr}></div>
69
+ </section>#{scripts}
70
+ HTML
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ Liquid::Template.register_tag("related_articles", Jekyll::ClientSearch::RelatedTag)