jekyll-client-search 0.1.0 → 0.3.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.
@@ -5,11 +5,21 @@ module Jekyll
5
5
  # Normalizes Jekyll documents and pages into the flat hash shape that the
6
6
  # search index JSON emits.
7
7
  class DocumentBuilder
8
- def from_document(document)
8
+ def from_document(document, source: nil, passthrough_fields: [])
9
9
  url = document.url.to_s
10
10
  return if url.empty?
11
11
 
12
12
  data = document.data
13
+ core_fields(document, data, url)
14
+ .compact
15
+ .merge("source" => source)
16
+ .merge(passthrough(data, passthrough_fields))
17
+ .compact
18
+ end
19
+
20
+ private
21
+
22
+ def core_fields(document, data, url)
13
23
  {
14
24
  "id" => url,
15
25
  "title" => clean(data["title"] || "Untitled"),
@@ -20,10 +30,18 @@ module Jekyll
20
30
  "tags" => normalize_list(data["tags"]),
21
31
  "date" => normalized_date(document),
22
32
  "date_timestamp" => normalized_timestamp(document)
23
- }.compact
33
+ }
24
34
  end
25
35
 
26
- private
36
+ def passthrough(data, fields)
37
+ fields.each_with_object({}) do |(source, target), result|
38
+ value = data[source]
39
+ next if value.nil?
40
+ next if value.is_a?(String) && value.empty?
41
+
42
+ result[target] = value
43
+ end
44
+ end
27
45
 
28
46
  def excerpt_for(document)
29
47
  document.excerpt if document.respond_to?(:excerpt)
@@ -49,8 +67,8 @@ module Jekyll
49
67
 
50
68
  def clean(value)
51
69
  cleaned = value.to_s
52
- .gsub(%r{<script\b[^>]*>.*?</script>}mi, " ")
53
- .gsub(%r{<style\b[^>]*>.*?</style>}mi, " ")
70
+ .gsub(%r{<script\b[^>]*>.*?</script\s*>}mi, " ")
71
+ .gsub(%r{<style\b[^>]*>.*?</style\s*>}mi, " ")
54
72
  .gsub(/\{%.*?%\}/m, " ")
55
73
  .gsub(/\{\{.*?\}\}/m, " ")
56
74
  .gsub(/!\[[^\]]*\]\([^)]*\)/, " ")
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Jekyll
6
+ module ClientSearch
7
+ # Validates optional compact dropdown live-search settings.
8
+ class DropdownConfiguration
9
+ DEFAULTS = {
10
+ "enabled" => true,
11
+ "max_items" => 5,
12
+ "min_chars" => 2,
13
+ "debounce_ms" => 150,
14
+ "redirect_url" => "/search/"
15
+ }.freeze
16
+
17
+ def initialize(config)
18
+ unless config.is_a?(Hash)
19
+ raise Jekyll::Errors::FatalException,
20
+ "client_search dropdown configuration must be a mapping"
21
+ end
22
+
23
+ @values = DEFAULTS.merge(config)
24
+ validate!
25
+ end
26
+
27
+ def enabled?
28
+ @values.fetch("enabled") != false
29
+ end
30
+
31
+ def max_items
32
+ @values.fetch("max_items")
33
+ end
34
+
35
+ def min_chars
36
+ @values.fetch("min_chars")
37
+ end
38
+
39
+ def debounce_ms
40
+ @values.fetch("debounce_ms")
41
+ end
42
+
43
+ def redirect_url
44
+ @values.fetch("redirect_url").to_s
45
+ end
46
+
47
+ def to_h
48
+ {
49
+ "enabled" => enabled?,
50
+ "maxItems" => max_items,
51
+ "minChars" => min_chars,
52
+ "debounceMs" => debounce_ms,
53
+ "redirectUrl" => redirect_url
54
+ }
55
+ end
56
+
57
+ private
58
+
59
+ def validate!
60
+ validate_boolean!("enabled")
61
+ validate_integer!("max_items", minimum: 1)
62
+ validate_integer!("min_chars", minimum: 0)
63
+ validate_integer!("debounce_ms", minimum: 0)
64
+ if redirect_url.empty?
65
+ raise Jekyll::Errors::FatalException,
66
+ "client_search dropdown redirect_url must not be empty"
67
+ end
68
+
69
+ uri = URI.parse(redirect_url)
70
+ return if uri.scheme.nil? && uri.host.nil? && !redirect_url.start_with?("//")
71
+
72
+ raise Jekyll::Errors::FatalException,
73
+ "client_search dropdown redirect_url must be a relative path"
74
+ end
75
+
76
+ def validate_boolean!(key)
77
+ return if [true, false].include?(@values[key])
78
+
79
+ raise Jekyll::Errors::FatalException,
80
+ "client_search dropdown.#{key} must be true or false"
81
+ end
82
+
83
+ def validate_integer!(key, minimum:)
84
+ value = @values.fetch(key)
85
+ return if value.is_a?(Integer) && value >= minimum
86
+
87
+ raise Jekyll::Errors::FatalException,
88
+ "client_search dropdown.#{key} must be an integer greater than or equal to #{minimum}"
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Jekyll
6
+ module ClientSearch
7
+ # Liquid tag that renders a compact live-search dropdown suitable for
8
+ # navbars and headers. Framework-agnostic — emits semantic HTML with
9
+ # data attributes, no CSS classes from any framework.
10
+ #
11
+ # {% search_dropdown %}
12
+ # {% search_dropdown max:10 %}
13
+ # {% search_dropdown scripts_only %}
14
+ # {% search_dropdown no_scripts %}
15
+ #
16
+ # When client_search is disabled the tag renders nothing.
17
+ class DropdownTag < Liquid::Tag
18
+ SYNTAX = /\A(max:(\d+))?\s*(scripts_only|no_scripts)?\z/
19
+
20
+ def initialize(tag_name, markup, tokens)
21
+ super
22
+ @markup = markup.to_s.strip
23
+ unless (match = @markup.match(SYNTAX))
24
+ raise Liquid::SyntaxError,
25
+ "search_dropdown: invalid syntax. Use {% search_dropdown %}, " \
26
+ "{% search_dropdown max:10 %}, {% search_dropdown scripts_only %}, " \
27
+ "or {% search_dropdown no_scripts %}"
28
+ end
29
+
30
+ @max_items = match[2].to_i if match[2]
31
+ raise Liquid::SyntaxError, "search_dropdown: max must be greater than zero" if @max_items && @max_items < 1
32
+
33
+ @mode = match[3] || "full"
34
+ @input_id = "cs-dropdown-input-#{object_id}"
35
+ @results_id = "cs-dropdown-results-#{object_id}"
36
+ end
37
+
38
+ def render(context)
39
+ site = context.registers[:site]
40
+ return "" unless enabled?(site)
41
+
42
+ configuration = Configuration.new(site)
43
+ return "" unless configuration.dropdown_enabled?
44
+
45
+ render_mode(configuration, site)
46
+ end
47
+
48
+ private
49
+
50
+ def enabled?(site)
51
+ return false if site.nil?
52
+
53
+ config_hash = site.config.fetch("client_search", {})
54
+ config_hash != false && config_hash["enabled"] != false
55
+ end
56
+
57
+ def render_mode(configuration, site)
58
+ baseurl = site.config["baseurl"].to_s.gsub(%r{\A/+|/+$}, "")
59
+ prefix = baseurl.empty? ? "" : "/#{baseurl}"
60
+ max = resolve_max(configuration)
61
+
62
+ form_html = build_form(max)
63
+ return form_html if @mode == "no_scripts"
64
+
65
+ scripts = build_scripts(configuration, prefix)
66
+ return scripts if @mode == "scripts_only"
67
+
68
+ "#{form_html}\n#{scripts}"
69
+ end
70
+
71
+ def resolve_max(configuration)
72
+ @max_items || configuration.dropdown_max_items
73
+ end
74
+
75
+ def build_form(max)
76
+ <<~HTML
77
+ <div class="client-search-dropdown" data-client-search-dropdown>
78
+ <form role="search" autocomplete="off" data-cs-dropdown-form>
79
+ <label class="sr-only" for="#{@input_id}">Search</label>
80
+ <input id="#{@input_id}" type="search" name="q"
81
+ placeholder="Search…" autocomplete="off"
82
+ aria-expanded="false" aria-controls="#{@results_id}"
83
+ data-cs-dropdown-input>
84
+ </form>
85
+ <ul id="#{@results_id}" role="listbox" aria-hidden="true"
86
+ data-cs-dropdown-results data-max-items="#{max}"></ul>
87
+ </div>
88
+ HTML
89
+ end
90
+
91
+ def build_scripts(configuration, prefix)
92
+ scripts = []
93
+ engine = configuration.engine_url
94
+ if engine
95
+ attrs = ["src=\"#{CGI.escapeHTML(engine)}\""]
96
+ if configuration.engine_crossorigin
97
+ crossorigin = CGI.escapeHTML(configuration.engine_crossorigin)
98
+ attrs << "crossorigin=\"#{crossorigin}\""
99
+ end
100
+ if configuration.engine_sri
101
+ integrity = CGI.escapeHTML(configuration.engine_sri)
102
+ attrs << "integrity=\"#{integrity}\""
103
+ end
104
+ scripts << "<script #{attrs.join(' ')}></script>"
105
+ end
106
+ scripts << "<script src=\"#{prefix}/assets/search-runtime-config.js\"></script>"
107
+ scripts << "<script src=\"#{prefix}/assets/client-search-dropdown.js\"></script>"
108
+ scripts << "<script src=\"#{prefix}/assets/adapters/#{configuration.engine}.js\"></script>"
109
+ scripts.map { |script| " #{script}" }.join("\n")
110
+ end
111
+ end
112
+ end
113
+ end
114
+
115
+ Liquid::Template.register_tag("search_dropdown", Jekyll::ClientSearch::DropdownTag)
@@ -90,19 +90,23 @@ module Jekyll
90
90
  end
91
91
 
92
92
  def collection_documents(site, configuration, builder)
93
+ fields = configuration.passthrough_fields
93
94
  configuration.collections.flat_map do |label|
94
95
  collection = label == "posts" ? site.posts : site.collections[label]
95
96
  next [] unless collection
96
97
 
97
- collection.docs.filter_map { |document| builder.from_document(document) }
98
+ collection.docs.filter_map do |document|
99
+ builder.from_document(document, source: label, passthrough_fields: fields)
100
+ end
98
101
  end
99
102
  end
100
103
 
101
104
  def page_documents(site, builder, configuration)
105
+ fields = configuration.passthrough_fields
102
106
  site.pages
103
107
  .reject { |page| page.url == "/#{configuration.output}" }
104
108
  .select { |page| page.data["title"] }
105
- .filter_map { |page| builder.from_document(page) }
109
+ .filter_map { |page| builder.from_document(page, source: "pages", passthrough_fields: fields) }
106
110
  end
107
111
 
108
112
  def add_runtime_asset(site, configuration)
@@ -12,7 +12,7 @@ module Jekyll
12
12
  "include_parent_domains" => true,
13
13
  "semantic" => true,
14
14
  "minimum_similarity" => 0.55,
15
- "max_items" => nil
15
+ "max_items" => 5
16
16
  }.freeze
17
17
 
18
18
  def initialize(config)
@@ -7,35 +7,45 @@ module Jekyll
7
7
  #
8
8
  # {% related_articles %}
9
9
  # {% related_articles sort:date %}
10
+ # {% related_articles max:3 %}
11
+ # {% related_articles sort:date max:3 %}
10
12
  # {% related_articles no_scripts %}
11
13
  #
12
14
  # When +related.enabled+ is false the tag renders nothing, so it is safe
13
15
  # to leave in a layout even when the feature is off.
14
16
  class RelatedTag < Liquid::Tag
15
- SYNTAX = /\A\s*(sort:(\w+))?\s*(no_scripts)?\s*\z/
17
+ SYNTAX = /\A(sort:(\w+))?\s*(max:(\d+))?\s*(no_scripts)?\z/
16
18
 
17
19
  def initialize(tag_name, markup, tokens)
18
20
  super
19
- @markup = markup.to_s
21
+ @markup = markup.to_s.strip
20
22
  unless (match = @markup.match(SYNTAX))
21
23
  raise Liquid::SyntaxError,
22
24
  "related_articles: invalid syntax. Use {% related_articles %}, " \
23
- "{% related_articles sort:date %}, or {% related_articles no_scripts %}"
25
+ "{% related_articles sort:date %}, {% related_articles max:3 %}, " \
26
+ "or {% related_articles no_scripts %}"
24
27
  end
25
28
 
26
29
  @sort = match[2] if match[2]
27
- @include_scripts = match[3].nil?
30
+ @max_items = match[4].to_i if match[4]
31
+ raise Liquid::SyntaxError, "related_articles: max must be greater than zero" if @max_items && @max_items < 1
32
+
33
+ @include_scripts = match[5].nil?
28
34
  end
29
35
 
30
36
  def render(context)
31
37
  site = context.registers[:site]
32
- config = site&.config&.fetch("client_search", {})
38
+ return "" if site.nil?
39
+
40
+ config = site.config.fetch("client_search", {})
41
+ return "" if config == false
33
42
  return "" unless related_enabled?(config)
34
43
 
35
44
  asset_prefix = asset_prefix(site)
36
45
  sort_attr = @sort ? " data-related-sort=\"#{@sort}\"" : ""
46
+ max_attr = resolve_max_attr(config)
37
47
  scripts = build_scripts(asset_prefix)
38
- build_html(sort_attr, scripts)
48
+ build_html(sort_attr, max_attr, scripts)
39
49
  end
40
50
 
41
51
  private
@@ -57,7 +67,19 @@ module Jekyll
57
67
  "<script src=\"#{prefix}/assets/client-search-related.js\"></script>"
58
68
  end
59
69
 
60
- def build_html(sort_attr, scripts)
70
+ def resolve_max_attr(config)
71
+ related = config.fetch("related", {})
72
+ max = if @max_items
73
+ @max_items
74
+ elsif related.key?("max_items")
75
+ related["max_items"]
76
+ else
77
+ 5
78
+ end
79
+ max.nil? ? "" : " data-related-max=\"#{max}\""
80
+ end
81
+
82
+ def build_html(sort_attr, max_attr, scripts)
61
83
  <<~HTML
62
84
  <section class="related-articles-section">
63
85
  <label for="related-sort">Sort related articles</label>
@@ -65,7 +87,7 @@ module Jekyll
65
87
  <option value="relevance">Most related</option>
66
88
  <option value="date">Newest</option>
67
89
  </select>
68
- <div id="related-articles"#{sort_attr}></div>
90
+ <div id="related-articles"#{sort_attr}#{max_attr}></div>
69
91
  </section>#{scripts}
70
92
  HTML
71
93
  end
@@ -12,10 +12,14 @@ module Jekyll
12
12
  "liveSearch" => configuration.live_search_config
13
13
  }
14
14
  defaults["relatedUrl"] = index_url(site, configuration.related_output) if configuration.related_enabled?
15
+ defaults["iconField"] = configuration.runtime_icon_field if configuration.runtime_icon_field
16
+ defaults["dropdown"] = configuration.dropdown_config if configuration.dropdown_enabled?
15
17
  json = JSON.generate(defaults)
16
18
  self.content = "window.clientSearchConfig = (function (generated, existing) {" \
17
19
  "var liveSearch = Object.assign({}, generated.liveSearch, existing.liveSearch || {});" \
18
- "return Object.assign(generated, existing, { liveSearch: liveSearch });" \
20
+ "var dropdown = Object.assign({}, generated.dropdown, existing.dropdown || {});" \
21
+ "return Object.assign(generated, existing, " \
22
+ "{ liveSearch: liveSearch, dropdown: dropdown });" \
19
23
  "}(#{json}, window.clientSearchConfig || {}));\n"
20
24
  end
21
25
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cgi"
4
+
3
5
  module Jekyll
4
6
  module ClientSearch
5
7
  # Liquid tag that renders the search form, status/results containers,
@@ -12,11 +14,11 @@ module Jekyll
12
14
  #
13
15
  # When client_search is disabled the tag renders nothing.
14
16
  class SearchTag < Liquid::Tag
15
- SYNTAX = /\A\s*(scripts_only|no_scripts)?\s*\z/
17
+ SYNTAX = /\A(scripts_only|no_scripts)?\z/
16
18
 
17
19
  def initialize(tag_name, markup, tokens)
18
20
  super
19
- @markup = markup.to_s
21
+ @markup = markup.to_s.strip
20
22
  unless (match = @markup.match(SYNTAX))
21
23
  raise Liquid::SyntaxError,
22
24
  "search_form: invalid syntax. Use {% search_form %}, " \
@@ -28,7 +30,10 @@ module Jekyll
28
30
 
29
31
  def render(context)
30
32
  site = context.registers[:site]
31
- config_hash = site&.config&.fetch("client_search", {})
33
+ return "" if site.nil?
34
+
35
+ config_hash = site.config.fetch("client_search", {})
36
+ return "" if config_hash == false
32
37
  return "" unless config_hash["enabled"] != false
33
38
 
34
39
  configuration = Configuration.new(site)
@@ -73,9 +78,15 @@ module Jekyll
73
78
  url = configuration.engine_url
74
79
  return nil unless url
75
80
 
76
- attrs = ["src=\"#{url}\""]
77
- attrs << "crossorigin=\"#{configuration.engine_crossorigin}\"" if configuration.engine_crossorigin
78
- attrs << "integrity=\"#{configuration.engine_sri}\"" if configuration.engine_sri
81
+ attrs = ["src=\"#{CGI.escapeHTML(url)}\""]
82
+ if configuration.engine_crossorigin
83
+ crossorigin = CGI.escapeHTML(configuration.engine_crossorigin)
84
+ attrs << "crossorigin=\"#{crossorigin}\""
85
+ end
86
+ if configuration.engine_sri
87
+ integrity = CGI.escapeHTML(configuration.engine_sri)
88
+ attrs << "integrity=\"#{integrity}\""
89
+ end
79
90
  "<script #{attrs.join(' ')}></script>"
80
91
  end
81
92
 
@@ -84,7 +95,13 @@ module Jekyll
84
95
 
85
96
  scripts = ["<script src=\"#{prefix}/assets/search-embedder-config.js\"></script>"]
86
97
  embedder_asset = configuration.query_embedder_asset
98
+ # simplecov:disable branch
99
+ # Defensive guard: semantic_with_embedder? already excludes the "none"
100
+ # type, and both "transformers" and "ollama_api" always return an asset.
101
+ # This branch is unreachable with current embedder types but protects
102
+ # against future types that may have no script asset.
87
103
  scripts << "<script src=\"#{prefix}/#{embedder_asset}\"></script>" if embedder_asset
104
+ # simplecov:enable branch
88
105
  scripts
89
106
  end
90
107
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Jekyll
4
4
  module ClientSearch
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -9,6 +9,7 @@ require_relative "client_search/version"
9
9
  require_relative "client_search/embedding_configuration"
10
10
  require_relative "client_search/live_search_configuration"
11
11
  require_relative "client_search/related_configuration"
12
+ require_relative "client_search/dropdown_configuration"
12
13
  require_relative "client_search/query_embedder_configuration"
13
14
  require_relative "client_search/configuration_accessors"
14
15
  require_relative "client_search/configuration"
@@ -22,4 +23,5 @@ require_relative "client_search/index_cache"
22
23
  require_relative "client_search/ollama_embedding_adapter"
23
24
  require_relative "client_search/generator"
24
25
  require_relative "client_search/related_tag"
26
+ require_relative "client_search/dropdown_tag"
25
27
  require_relative "client_search/search_tag"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-client-search
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svend Gundestrup
@@ -46,6 +46,7 @@ files:
46
46
  - assets/adapters/minisearch.js
47
47
  - assets/adapters/semantic.js
48
48
  - assets/client-search-base.js
49
+ - assets/client-search-dropdown.js
49
50
  - assets/client-search-related.js
50
51
  - assets/includes/related-articles.html
51
52
  - assets/layouts/post-with-related.html
@@ -59,6 +60,8 @@ files:
59
60
  - lib/jekyll/client_search/configuration.rb
60
61
  - lib/jekyll/client_search/configuration_accessors.rb
61
62
  - lib/jekyll/client_search/document_builder.rb
63
+ - lib/jekyll/client_search/dropdown_configuration.rb
64
+ - lib/jekyll/client_search/dropdown_tag.rb
62
65
  - lib/jekyll/client_search/embedder_config_page.rb
63
66
  - lib/jekyll/client_search/embedding_configuration.rb
64
67
  - lib/jekyll/client_search/generator.rb