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,133 @@
1
+ <svg width="900" height="950" viewBox="0 0 900 950" xmlns="http://www.w3.org/2000/svg">
2
+
3
+ <defs>
4
+ <style>
5
+ .dark { fill:#101826; }
6
+ .accent { fill:#e45842; }
7
+ .title {
8
+ font-family: Inter, Arial, Helvetica, sans-serif;
9
+ font-size: 88px;
10
+ font-weight: 700;
11
+ }
12
+ .subtitle {
13
+ font-family: Inter, Arial, Helvetica, sans-serif;
14
+ font-size: 32px;
15
+ fill: #6b7280;
16
+ letter-spacing: 3px;
17
+ }
18
+ </style>
19
+ </defs>
20
+
21
+ <!-- Main frame -->
22
+ <rect x="180"
23
+ y="40"
24
+ width="540"
25
+ height="540"
26
+ rx="45"
27
+ fill="white"
28
+ stroke="#101826"
29
+ stroke-width="16"/>
30
+
31
+ <!-- document -->
32
+
33
+ <rect x="280"
34
+ y="130"
35
+ width="250"
36
+ height="320"
37
+ rx="18"
38
+ fill="#101826"/>
39
+
40
+ <!-- folded corner -->
41
+ <polygon points="470,130 530,130 530,190"
42
+ class="accent"/>
43
+
44
+ <!-- text lines -->
45
+ <rect x="320" y="200" width="170" height="12" fill="white" opacity="0.9"/>
46
+ <rect x="320" y="245" width="140" height="12" fill="white" opacity="0.9"/>
47
+ <rect x="320" y="290" width="160" height="12" fill="white" opacity="0.9"/>
48
+ <rect x="320" y="335" width="120" height="12" fill="white" opacity="0.9"/>
49
+
50
+ <!-- accent highlight -->
51
+ <rect x="280"
52
+ y="395"
53
+ width="250"
54
+ height="55"
55
+ class="accent"/>
56
+
57
+ <!-- search badge -->
58
+ <g transform="translate(620,460)">
59
+
60
+ <circle r="120"
61
+ fill="#101826"
62
+ stroke="white"
63
+ stroke-width="8"/>
64
+
65
+ <!-- magnifier -->
66
+ <circle cx="-18"
67
+ cy="-18"
68
+ r="42"
69
+ fill="none"
70
+ stroke="white"
71
+ stroke-width="16"/>
72
+
73
+ <line x1="15"
74
+ y1="15"
75
+ x2="55"
76
+ y2="55"
77
+ stroke="white"
78
+ stroke-width="16"
79
+ stroke-linecap="round"/>
80
+
81
+ <!-- small accent dot -->
82
+ <circle cx="-70"
83
+ cy="-75"
84
+ r="10"
85
+ class="accent"/>
86
+
87
+ </g>
88
+
89
+ <!-- title -->
90
+
91
+ <text x="450"
92
+ y="720"
93
+ text-anchor="middle"
94
+ class="title">
95
+
96
+ <tspan fill="#101826">jekyll-client-</tspan>
97
+ <tspan fill="#e45842">search</tspan>
98
+
99
+ </text>
100
+
101
+ <!-- subtitle -->
102
+
103
+ <text x="450"
104
+ y="805"
105
+ text-anchor="middle"
106
+ class="subtitle">
107
+
108
+ FAST CLIENT-SIDE SEARCH FOR JEKYLL
109
+
110
+ </text>
111
+
112
+ <!-- decorative divider -->
113
+
114
+ <line x1="330"
115
+ y1="860"
116
+ x2="420"
117
+ y2="860"
118
+ stroke="#101826"
119
+ stroke-width="4"/>
120
+
121
+ <circle cx="450"
122
+ cy="860"
123
+ r="8"
124
+ class="accent"/>
125
+
126
+ <line x1="480"
127
+ y1="860"
128
+ x2="570"
129
+ y2="860"
130
+ stroke="#101826"
131
+ stroke-width="4"/>
132
+
133
+ </svg>
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ClientSearch
5
+ # Reads the +client_search+ section of the Jekyll site config and exposes
6
+ # validated, normalized values to the generator.
7
+ class Configuration
8
+ include ConfigurationAccessors
9
+ include EmbeddingConfiguration
10
+
11
+ ENGINES = %w[minisearch elasticlunr semantic].freeze
12
+
13
+ ENGINE_CDN_URLS = {
14
+ "minisearch" => "https://cdn.jsdelivr.net/npm/minisearch@7.2.0/dist/umd/index.min.js",
15
+ "elasticlunr" => "https://cdn.jsdelivr.net/npm/elasticlunr@0.9.5/elasticlunr.min.js",
16
+ "semantic" => nil
17
+ }.freeze
18
+
19
+ DEFAULTS = {
20
+ "enabled" => true,
21
+ "engine" => "minisearch",
22
+ "output" => "search-index.json",
23
+ "collections" => ["posts"],
24
+ "include_pages" => false,
25
+ "copy_runtime" => true,
26
+ "embedding" => {
27
+ "enabled" => false,
28
+ "model" => "embeddinggemma:300m",
29
+ "base_url" => "http://localhost:11434",
30
+ "connect_timeout" => 5,
31
+ "read_timeout" => 120,
32
+ "fail_on_error" => true,
33
+ "query_embedder" => {
34
+ "type" => "transformers"
35
+ }
36
+ }
37
+ }.freeze
38
+
39
+ def initialize(site)
40
+ configured = site.config["client_search"]
41
+ configured = { "enabled" => false } if configured == false
42
+ configured ||= {}
43
+ unless configured.is_a?(Hash)
44
+ raise Jekyll::Errors::FatalException,
45
+ "client_search configuration must be a mapping or false"
46
+ end
47
+
48
+ @values = DEFAULTS.merge(configured)
49
+ @live_search = LiveSearchConfiguration.new(configured["live_search"] || {}, engine: engine)
50
+ @related = RelatedConfiguration.new(configured["related"] || {})
51
+ merge_embedding_config(configured["embedding"])
52
+ validate_engine!
53
+ return unless embedding_enabled?
54
+
55
+ validate_embedding!
56
+ validate_query_embedder!
57
+ end
58
+
59
+ private
60
+
61
+ def merge_embedding_config(configured_embedding)
62
+ embedding = configured_embedding || {}
63
+ unless embedding.is_a?(Hash)
64
+ raise Jekyll::Errors::FatalException,
65
+ "client_search embedding configuration must be a mapping"
66
+ end
67
+ @values["embedding"] = DEFAULTS.fetch("embedding").merge(embedding)
68
+ merge_query_embedder_config(embedding["query_embedder"])
69
+ end
70
+
71
+ def merge_query_embedder_config(configured_query_embedder)
72
+ @query_embedder = QueryEmbedderConfiguration.new(
73
+ configured_query_embedder || {},
74
+ build_model: embedding_model,
75
+ build_base_url: embedding_base_url,
76
+ query_prefix: embedding_query_prefix
77
+ )
78
+ end
79
+
80
+ public
81
+
82
+ def enabled?
83
+ @values["enabled"] != false
84
+ end
85
+
86
+ def engine
87
+ @values.fetch("engine").to_s
88
+ end
89
+
90
+ def engine_url
91
+ @values.key?("engine_url") ? @values.fetch("engine_url") : ENGINE_CDN_URLS.fetch(engine)
92
+ end
93
+
94
+ def engine_sri
95
+ @values.fetch("engine_sri", nil)
96
+ end
97
+
98
+ def engine_crossorigin
99
+ @values.fetch("engine_crossorigin", nil)
100
+ end
101
+
102
+ def runtime_assets
103
+ assets = ["assets/client-search-base.js", "assets/adapters/#{engine}.js"]
104
+ assets.concat(query_embedder_assets) if engine == "semantic" && embedding_enabled?
105
+ assets << "assets/client-search-related.js" if related_enabled?
106
+ assets
107
+ end
108
+
109
+ def output
110
+ normalize_output(@values.fetch("output"))
111
+ end
112
+
113
+ def collections
114
+ Array(@values["collections"])
115
+ .compact
116
+ .map(&:to_s)
117
+ .reject(&:empty?)
118
+ .uniq
119
+ end
120
+
121
+ def include_pages?
122
+ @values["include_pages"] == true
123
+ end
124
+
125
+ def copy_runtime?
126
+ @values["copy_runtime"] != false
127
+ end
128
+
129
+ private
130
+
131
+ def validate_query_embedder!
132
+ query_embedder_model if engine == "semantic" && query_embedder_type == "transformers"
133
+ end
134
+
135
+ def validate_engine!
136
+ return if ENGINES.include?(engine)
137
+
138
+ raise Jekyll::Errors::FatalException,
139
+ "client_search engine must be one of #{ENGINES.join(', ')} (got #{engine.inspect})"
140
+ end
141
+
142
+ def normalize_output(value)
143
+ output = value.to_s.sub(%r{\A/+}, "")
144
+ normalized = Pathname.new(output).cleanpath.to_s
145
+ return normalized unless output.empty? || normalized == "." || normalized.start_with?("../")
146
+
147
+ raise Jekyll::Errors::FatalException,
148
+ "client_search output must be a relative file path inside the destination"
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ClientSearch
5
+ # Exposes auxiliary generated-runtime settings through Configuration.
6
+ module ConfigurationAccessors
7
+ def live_search_config
8
+ @live_search.to_h(engine: engine)
9
+ end
10
+
11
+ def related_enabled?
12
+ @related.enabled?
13
+ end
14
+
15
+ def related_output
16
+ @related.output
17
+ end
18
+
19
+ def related_configuration
20
+ @related
21
+ end
22
+
23
+ def query_embedder_type
24
+ @query_embedder.type
25
+ end
26
+
27
+ def query_embedder_model
28
+ @query_embedder.model
29
+ end
30
+
31
+ def query_embedder_api_url
32
+ @query_embedder.api_url
33
+ end
34
+
35
+ def query_embedder_asset
36
+ @query_embedder.asset
37
+ end
38
+
39
+ def query_embedder_assets
40
+ @query_embedder.assets
41
+ end
42
+
43
+ def query_embedder_config_json
44
+ @query_embedder.to_json
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ClientSearch
5
+ # Normalizes Jekyll documents and pages into the flat hash shape that the
6
+ # search index JSON emits.
7
+ class DocumentBuilder
8
+ def from_document(document)
9
+ url = document.url.to_s
10
+ return if url.empty?
11
+
12
+ data = document.data
13
+ {
14
+ "id" => url,
15
+ "title" => clean(data["title"] || "Untitled"),
16
+ "url" => url,
17
+ "excerpt" => clean(data["excerpt"] || excerpt_for(document)),
18
+ "content" => clean(document.content),
19
+ "categories" => normalize_list(data["categories"]),
20
+ "tags" => normalize_list(data["tags"]),
21
+ "date" => normalized_date(document),
22
+ "date_timestamp" => normalized_timestamp(document)
23
+ }.compact
24
+ end
25
+
26
+ private
27
+
28
+ def excerpt_for(document)
29
+ document.excerpt if document.respond_to?(:excerpt)
30
+ end
31
+
32
+ def normalize_list(value)
33
+ Array(value).compact.map { |item| clean(item) }.reject(&:empty?).uniq
34
+ end
35
+
36
+ def normalized_date(document)
37
+ value = document.data["date"] || document.date if document.respond_to?(:date)
38
+ return if value.nil? || value.to_s.empty?
39
+
40
+ value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
41
+ end
42
+
43
+ def normalized_timestamp(document)
44
+ date = normalized_date(document)
45
+ date ? Time.iso8601(date).to_i : nil
46
+ rescue ArgumentError
47
+ nil
48
+ end
49
+
50
+ def clean(value)
51
+ cleaned = value.to_s
52
+ .gsub(%r{<script\b[^>]*>.*?</script>}mi, " ")
53
+ .gsub(%r{<style\b[^>]*>.*?</style>}mi, " ")
54
+ .gsub(/\{%.*?%\}/m, " ")
55
+ .gsub(/\{\{.*?\}\}/m, " ")
56
+ .gsub(/!\[[^\]]*\]\([^)]*\)/, " ")
57
+ .gsub(/<[^>]+>/, " ")
58
+ .gsub(/[`*_>#\[\]()]/, " ")
59
+ .gsub(/\s+/, " ")
60
+ .gsub(/\s+([,.!?;:])/, '\\1')
61
+ .strip
62
+ CGI.unescapeHTML(cleaned)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ClientSearch
5
+ # Generates the browser query-embedder configuration JavaScript.
6
+ class EmbedderConfigPage < Jekyll::PageWithoutAFile
7
+ def initialize(site, configuration)
8
+ super(site, site.source, "assets", "search-embedder-config.js")
9
+ self.data = { "layout" => nil, "sitemap" => false }
10
+ self.content = "window.ClientSearchEmbedderConfig = #{configuration.query_embedder_config_json};\n"
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ClientSearch
5
+ # Provides build-time embedding settings and model-specific prefixes.
6
+ module EmbeddingConfiguration
7
+ PREFIXES = {
8
+ "embeddinggemma" => {
9
+ "document" => "title: none | text: ",
10
+ "query" => "task: search result | query: "
11
+ },
12
+ "nomic-embed-text" => {
13
+ "document" => "search_document: ",
14
+ "query" => "search_query: "
15
+ }
16
+ }.freeze
17
+
18
+ def embedding_enabled?
19
+ @values.fetch("embedding").fetch("enabled") == true
20
+ end
21
+
22
+ def embedding_model
23
+ @values.fetch("embedding").fetch("model").to_s
24
+ end
25
+
26
+ def embedding_base_url
27
+ @values.fetch("embedding").fetch("base_url").to_s
28
+ end
29
+
30
+ def embedding_connect_timeout
31
+ positive_timeout("connect_timeout")
32
+ end
33
+
34
+ def embedding_read_timeout
35
+ positive_timeout("read_timeout")
36
+ end
37
+
38
+ def embedding_fail_on_error?
39
+ @values.fetch("embedding").fetch("fail_on_error") != false
40
+ end
41
+
42
+ def embedding_include_in_index?
43
+ configured = @values.fetch("embedding")["include_in_index"]
44
+ configured.nil? ? engine == "semantic" : configured == true
45
+ end
46
+
47
+ def embedding_document_prefix
48
+ configured_prefix("document_prefix", "document")
49
+ end
50
+
51
+ def embedding_query_prefix
52
+ configured_prefix("query_prefix", "query")
53
+ end
54
+
55
+ def embedding_identity
56
+ {
57
+ "provider" => "ollama",
58
+ "model" => embedding_model,
59
+ "base_url" => embedding_base_url,
60
+ "document_prefix" => embedding_document_prefix,
61
+ "query_prefix" => embedding_query_prefix,
62
+ "schema" => 2
63
+ }
64
+ end
65
+
66
+ private
67
+
68
+ def configured_prefix(config_key, role)
69
+ embedding = @values.fetch("embedding")
70
+ return embedding.fetch(config_key).to_s if embedding.key?(config_key)
71
+
72
+ model_name = embedding_model.split(":").first
73
+ PREFIXES.fetch(model_name, {}).fetch(role, "")
74
+ end
75
+
76
+ def positive_timeout(key)
77
+ value = @values.fetch("embedding").fetch(key).to_f
78
+ unless value.positive? && value.finite?
79
+ raise Jekyll::Errors::FatalException,
80
+ "embedding #{key} must be a finite value greater than zero"
81
+ end
82
+
83
+ value
84
+ end
85
+
86
+ def validate_embedding!
87
+ raise Jekyll::Errors::FatalException, "embedding model must not be empty" if embedding_model.empty?
88
+ raise Jekyll::Errors::FatalException, "embedding base_url must not be empty" if embedding_base_url.empty?
89
+
90
+ embedding_connect_timeout
91
+ embedding_read_timeout
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ClientSearch
5
+ # Jekyll generator that emits the search index JSON and copies browser
6
+ # runtime assets (base + selected engine adapter) for client-side
7
+ # JavaScript search engines. Optionally enhances documents with
8
+ # embeddings from a local Ollama server, using a content-hash cache to
9
+ # avoid re-embedding unchanged documents.
10
+ class Generator < Jekyll::Generator
11
+ safe true
12
+ priority :low
13
+
14
+ def generate(site)
15
+ configuration = Configuration.new(site)
16
+ return unless configuration.enabled?
17
+
18
+ documents = build_documents(site, configuration)
19
+ documents = enhance_with_embeddings(site, documents, configuration) if configuration.embedding_enabled?
20
+ add_generated_pages(site, documents, configuration)
21
+ add_runtime_asset(site, configuration) if configuration.copy_runtime?
22
+ end
23
+
24
+ private
25
+
26
+ def add_generated_pages(site, documents, configuration)
27
+ add_related_page(site, documents, configuration) if configuration.related_enabled?
28
+ remove_embeddings(documents) unless configuration.embedding_include_in_index?
29
+ site.pages << SearchIndexPage.new(site, configuration.output, documents)
30
+ site.pages << RuntimeConfigPage.new(site, configuration) if configuration.copy_runtime?
31
+ add_embedder_config(site, configuration) if semantic_query_embedder?(configuration)
32
+ end
33
+
34
+ def build_documents(site, configuration)
35
+ builder = DocumentBuilder.new
36
+ documents = collection_documents(site, configuration, builder)
37
+ documents.concat(page_documents(site, builder, configuration)) if configuration.include_pages?
38
+ documents.compact.uniq { |document| document["id"] }
39
+ end
40
+
41
+ def enhance_with_embeddings(site, documents, configuration)
42
+ cache = IndexCache.new(site.source, embedding_identity: configuration.embedding_identity)
43
+ adapter = build_embedding_adapter(configuration)
44
+
45
+ documents.each { |entry| embed_document(entry, cache, adapter, configuration) }
46
+ cache.prune(documents.map { |doc| doc["id"] })
47
+ cache.save
48
+ documents
49
+ end
50
+
51
+ def build_embedding_adapter(configuration)
52
+ OllamaEmbeddingAdapter.new(
53
+ model: configuration.embedding_model,
54
+ base_url: configuration.embedding_base_url,
55
+ connect_timeout: configuration.embedding_connect_timeout,
56
+ read_timeout: configuration.embedding_read_timeout
57
+ )
58
+ end
59
+
60
+ def add_related_page(site, documents, configuration)
61
+ relation_data = RelatedAnalyzer.new(configuration.related_configuration).analyze(documents)
62
+ site.pages << RelatedPage.new(site, configuration.related_output, relation_data)
63
+ end
64
+
65
+ def remove_embeddings(documents)
66
+ documents.each { |document| document.delete("embedding") }
67
+ end
68
+
69
+ def embed_document(document, cache, adapter, configuration)
70
+ hash = IndexCache.content_hash(document)
71
+ cached = cache.lookup(document["id"], hash)
72
+ if cached && cached["embedding"]
73
+ document["embedding"] = cached["embedding"]
74
+ return
75
+ end
76
+
77
+ embedding = adapter.embed(embedding_text(document, configuration))
78
+ if embedding
79
+ document["embedding"] = embedding
80
+ cache.store(document["id"], hash, embedding)
81
+ elsif configuration.embedding_fail_on_error?
82
+ raise Jekyll::Errors::FatalException,
83
+ "embedding generation failed for document #{document['id'].inspect}"
84
+ end
85
+ end
86
+
87
+ def embedding_text(document, configuration)
88
+ text = [document["title"], document["excerpt"], document["content"]].compact.join(" ")
89
+ "#{configuration.embedding_document_prefix}#{text}"
90
+ end
91
+
92
+ def collection_documents(site, configuration, builder)
93
+ configuration.collections.flat_map do |label|
94
+ collection = label == "posts" ? site.posts : site.collections[label]
95
+ next [] unless collection
96
+
97
+ collection.docs.filter_map { |document| builder.from_document(document) }
98
+ end
99
+ end
100
+
101
+ def page_documents(site, builder, configuration)
102
+ site.pages
103
+ .reject { |page| page.url == "/#{configuration.output}" }
104
+ .select { |page| page.data["title"] }
105
+ .filter_map { |page| builder.from_document(page) }
106
+ end
107
+
108
+ def add_runtime_asset(site, configuration)
109
+ root = File.expand_path("../../..", __dir__)
110
+ configuration.runtime_assets.each do |asset|
111
+ relative = asset.sub(%r{\A/}, "")
112
+ next if site.static_files.any? { |file| normalized_path(file.relative_path) == relative }
113
+
114
+ base = File.dirname(asset)
115
+ name = File.basename(asset)
116
+ site.static_files << Jekyll::StaticFile.new(site, root, base, name)
117
+ end
118
+ end
119
+
120
+ def add_embedder_config(site, configuration)
121
+ site.pages << EmbedderConfigPage.new(site, configuration)
122
+ end
123
+
124
+ def semantic_query_embedder?(configuration)
125
+ configuration.engine == "semantic" && configuration.embedding_enabled? &&
126
+ configuration.query_embedder_type != "none"
127
+ end
128
+
129
+ def normalized_path(path)
130
+ path.to_s.sub(%r{\A/}, "")
131
+ end
132
+ end
133
+ end
134
+ end