markdowndocs 0.1.5 → 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 +4 -4
- data/CHANGELOG.md +15 -0
- data/app/assets/javascripts/markdowndocs/controllers/docs_search_controller.js +99 -0
- data/app/assets/javascripts/markdowndocs/vendor/minisearch.min.js +8 -0
- data/app/controllers/markdowndocs/docs_controller.rb +23 -0
- data/app/models/markdowndocs/documentation.rb +15 -0
- data/app/views/markdowndocs/docs/index.html.erb +40 -3
- data/config/routes.rb +1 -0
- data/lib/generators/markdowndocs/install/templates/initializer.rb +4 -0
- data/lib/markdowndocs/configuration.rb +2 -1
- data/lib/markdowndocs/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 70a3fffbcd50ae1f278895fe3bd5a1834916cc0f538218bf4b39c6901d4314ae
|
|
4
|
+
data.tar.gz: 120d2bb20c1b1ea471cdd68af794d2887c6817be63433d162ce93a06b652f880
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 526153fb60c2781e578c417000d3fec8048d9c955b9c652e67f2c39e9e3b1d6ba36efcd412bed60d714fe0f714e101fca6861c8932226fcc8589033baca1c519
|
|
7
|
+
data.tar.gz: c3ce1ff0c86b6bcd12763268069fe6f766ac64793e327000228316cc50c9a59ea42310f15599846a47533866ff35082ae573fac927425c05f997abd3000b3807
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.0] - 2026-02-21
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Opt-in full-text search for the documentation index page (`config.search_enabled = true`)
|
|
13
|
+
- Pre-built JSON search index served from `/docs/search_index` endpoint
|
|
14
|
+
- Instant search-as-you-type powered by vendored MiniSearch (~7KB gzipped)
|
|
15
|
+
- Stimulus controller (`docs_search_controller`) with debounced input, fuzzy matching, and prefix search
|
|
16
|
+
- Title matches boosted 3x, description matches boosted 2x for relevance ranking
|
|
17
|
+
- Cards and category sections auto-hide/show based on search results
|
|
18
|
+
- "No matching documents" empty state when search yields no results
|
|
19
|
+
- `plain_text_content` method on `Documentation` model for stripped searchable text
|
|
20
|
+
- Search index cached via `Rails.cache` with file-mtime-based invalidation
|
|
21
|
+
|
|
8
22
|
## [0.1.5] - 2026-02-20
|
|
9
23
|
|
|
10
24
|
### Changed
|
|
@@ -53,6 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
53
67
|
- i18n support for all UI strings
|
|
54
68
|
- Install generator (`rails generate markdowndocs:install`)
|
|
55
69
|
|
|
70
|
+
[0.2.0]: https://github.com/dschmura/markdowndocs/releases/tag/v0.2.0
|
|
56
71
|
[0.1.5]: https://github.com/dschmura/markdowndocs/releases/tag/v0.1.5
|
|
57
72
|
[0.1.4]: https://github.com/dschmura/markdowndocs/releases/tag/v0.1.4
|
|
58
73
|
[0.1.3]: https://github.com/dschmura/markdowndocs/releases/tag/v0.1.3
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["input", "category", "card", "noResults"]
|
|
5
|
+
static values = { indexUrl: String }
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.miniSearch = null
|
|
9
|
+
this.debounceTimer = null
|
|
10
|
+
this.loadIndex()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
disconnect() {
|
|
14
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async loadIndex() {
|
|
18
|
+
try {
|
|
19
|
+
const response = await fetch(this.indexUrlValue)
|
|
20
|
+
if (!response.ok) return
|
|
21
|
+
|
|
22
|
+
const docs = await response.json()
|
|
23
|
+
const MiniSearch = (await this.loadMiniSearch()).default || (await this.loadMiniSearch())
|
|
24
|
+
|
|
25
|
+
this.miniSearch = new MiniSearch({
|
|
26
|
+
fields: ["title", "description", "content"],
|
|
27
|
+
storeFields: ["title", "description"],
|
|
28
|
+
searchOptions: {
|
|
29
|
+
boost: { title: 3, description: 2 },
|
|
30
|
+
fuzzy: 0.2,
|
|
31
|
+
prefix: true
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
this.miniSearch.addAll(docs)
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.warn("Markdowndocs: failed to load search index", e)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async loadMiniSearch() {
|
|
41
|
+
// MiniSearch may already be loaded as a global (UMD)
|
|
42
|
+
if (typeof MiniSearch !== "undefined") return MiniSearch
|
|
43
|
+
|
|
44
|
+
// Try dynamic import for ES module environments
|
|
45
|
+
try {
|
|
46
|
+
return await import("minisearch")
|
|
47
|
+
} catch {
|
|
48
|
+
// Fallback: load the vendored UMD script
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const script = document.createElement("script")
|
|
51
|
+
script.src = this.element.dataset.minisearchUrl || "/assets/markdowndocs/vendor/minisearch.min.js"
|
|
52
|
+
script.onload = () => resolve(window.MiniSearch)
|
|
53
|
+
script.onerror = reject
|
|
54
|
+
document.head.appendChild(script)
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
search() {
|
|
60
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer)
|
|
61
|
+
this.debounceTimer = setTimeout(() => this.performSearch(), 150)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
performSearch() {
|
|
65
|
+
const query = this.inputTarget.value.trim()
|
|
66
|
+
|
|
67
|
+
if (!query || !this.miniSearch) {
|
|
68
|
+
this.showAll()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const results = this.miniSearch.search(query)
|
|
73
|
+
const matchingSlugs = new Set(results.map(r => r.id))
|
|
74
|
+
|
|
75
|
+
this.cardTargets.forEach(card => {
|
|
76
|
+
const slug = card.dataset.slug
|
|
77
|
+
card.classList.toggle("hidden", !matchingSlugs.has(slug))
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
this.categoryTargets.forEach(section => {
|
|
81
|
+
const cards = section.querySelectorAll("[data-docs-search-target='card']")
|
|
82
|
+
const hasVisible = Array.from(cards).some(c => !c.classList.contains("hidden"))
|
|
83
|
+
section.classList.toggle("hidden", !hasVisible)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const hasResults = matchingSlugs.size > 0
|
|
87
|
+
if (this.hasNoResultsTarget) {
|
|
88
|
+
this.noResultsTarget.classList.toggle("hidden", hasResults)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
showAll() {
|
|
93
|
+
this.cardTargets.forEach(card => card.classList.remove("hidden"))
|
|
94
|
+
this.categoryTargets.forEach(section => section.classList.remove("hidden"))
|
|
95
|
+
if (this.hasNoResultsTarget) {
|
|
96
|
+
this.noResultsTarget.classList.add("hidden")
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minified by jsDelivr using Terser v5.19.2.
|
|
3
|
+
* Original file: /npm/minisearch@7.1.1/dist/umd/index.js
|
|
4
|
+
*
|
|
5
|
+
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
|
6
|
+
*/
|
|
7
|
+
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).MiniSearch=e()}(this,(function(){"use strict";function t(t,e,s,i){return new(s||(s=Promise))((function(n,o){function r(t){try{u(i.next(t))}catch(t){o(t)}}function c(t){try{u(i.throw(t))}catch(t){o(t)}}function u(t){var e;t.done?n(t.value):(e=t.value,e instanceof s?e:new s((function(t){t(e)}))).then(r,c)}u((i=i.apply(t,e||[])).next())}))}"function"==typeof SuppressedError&&SuppressedError;const e="KEYS",s="VALUES",i="";class n{constructor(t,e){const s=t._tree,i=Array.from(s.keys());this.set=t,this._type=e,this._path=i.length>0?[{node:s,keys:i}]:[]}next(){const t=this.dive();return this.backtrack(),t}dive(){if(0===this._path.length)return{done:!0,value:void 0};const{node:t,keys:e}=o(this._path);if(o(e)===i)return{done:!1,value:this.result()};const s=t.get(o(e));return this._path.push({node:s,keys:Array.from(s.keys())}),this.dive()}backtrack(){if(0===this._path.length)return;const t=o(this._path).keys;t.pop(),t.length>0||(this._path.pop(),this.backtrack())}key(){return this.set._prefix+this._path.map((({keys:t})=>o(t))).filter((t=>t!==i)).join("")}value(){return o(this._path).node.get(i)}result(){switch(this._type){case s:return this.value();case e:return this.key();default:return[this.key(),this.value()]}}[Symbol.iterator](){return this}}const o=t=>t[t.length-1],r=(t,e,s,n,o,c,u,h)=>{const d=c*u;t:for(const a of t.keys())if(a===i){const e=o[d-1];e<=s&&n.set(h,[t.get(a),e])}else{let i=c;for(let t=0;t<a.length;++t,++i){const n=a[t],r=u*i,c=r-u;let h=o[r];const d=Math.max(0,i-s-1),l=Math.min(u-1,i+s);for(let t=d;t<l;++t){const s=n!==e[t],i=o[c+t]+ +s,u=o[c+t+1]+1,d=o[r+t]+1,a=o[r+t+1]=Math.min(i,u,d);a<h&&(h=a)}if(h>s)continue t}r(t.get(a),e,s,n,o,i,u,h+a)}};class c{constructor(t=new Map,e=""){this._size=void 0,this._tree=t,this._prefix=e}atPrefix(t){if(!t.startsWith(this._prefix))throw new Error("Mismatched prefix");const[e,s]=u(this._tree,t.slice(this._prefix.length));if(void 0===e){const[e,n]=m(s);for(const s of e.keys())if(s!==i&&s.startsWith(n)){const i=new Map;return i.set(s.slice(n.length),e.get(s)),new c(i,t)}}return new c(e,t)}clear(){this._size=void 0,this._tree.clear()}delete(t){return this._size=void 0,a(this._tree,t)}entries(){return new n(this,"ENTRIES")}forEach(t){for(const[e,s]of this)t(e,s,this)}fuzzyGet(t,e){return((t,e,s)=>{const i=new Map;if(void 0===e)return i;const n=e.length+1,o=n+s,c=new Uint8Array(o*n).fill(s+1);for(let t=0;t<n;++t)c[t]=t;for(let t=1;t<o;++t)c[t*n]=t;return r(t,e,s,i,c,1,n,""),i})(this._tree,t,e)}get(t){const e=h(this._tree,t);return void 0!==e?e.get(i):void 0}has(t){const e=h(this._tree,t);return void 0!==e&&e.has(i)}keys(){return new n(this,e)}set(t,e){if("string"!=typeof t)throw new Error("key must be a string");this._size=void 0;return d(this._tree,t).set(i,e),this}get size(){if(this._size)return this._size;this._size=0;const t=this.entries();for(;!t.next().done;)this._size+=1;return this._size}update(t,e){if("string"!=typeof t)throw new Error("key must be a string");this._size=void 0;const s=d(this._tree,t);return s.set(i,e(s.get(i))),this}fetch(t,e){if("string"!=typeof t)throw new Error("key must be a string");this._size=void 0;const s=d(this._tree,t);let n=s.get(i);return void 0===n&&s.set(i,n=e()),n}values(){return new n(this,s)}[Symbol.iterator](){return this.entries()}static from(t){const e=new c;for(const[s,i]of t)e.set(s,i);return e}static fromObject(t){return c.from(Object.entries(t))}}const u=(t,e,s=[])=>{if(0===e.length||null==t)return[t,s];for(const n of t.keys())if(n!==i&&e.startsWith(n))return s.push([t,n]),u(t.get(n),e.slice(n.length),s);return s.push([t,e]),u(void 0,"",s)},h=(t,e)=>{if(0===e.length||null==t)return t;for(const s of t.keys())if(s!==i&&e.startsWith(s))return h(t.get(s),e.slice(s.length))},d=(t,e)=>{const s=e.length;t:for(let n=0;t&&n<s;){for(const o of t.keys())if(o!==i&&e[n]===o[0]){const i=Math.min(s-n,o.length);let r=1;for(;r<i&&e[n+r]===o[r];)++r;const c=t.get(o);if(r===o.length)t=c;else{const s=new Map;s.set(o.slice(r),c),t.set(e.slice(n,n+r),s),t.delete(o),t=s}n+=r;continue t}const o=new Map;return t.set(e.slice(n),o),o}return t},a=(t,e)=>{const[s,n]=u(t,e);if(void 0!==s)if(s.delete(i),0===s.size)l(n);else if(1===s.size){const[t,e]=s.entries().next().value;f(n,t,e)}},l=t=>{if(0===t.length)return;const[e,s]=m(t);if(e.delete(s),0===e.size)l(t.slice(0,-1));else if(1===e.size){const[s,n]=e.entries().next().value;s!==i&&f(t.slice(0,-1),s,n)}},f=(t,e,s)=>{if(0===t.length)return;const[i,n]=m(t);i.set(n+e,s),i.delete(n)},m=t=>t[t.length-1],g="or";class _{constructor(t){if(null==(null==t?void 0:t.fields))throw new Error('MiniSearch: option "fields" must be provided');const e=null==t.autoVacuum||!0===t.autoVacuum?O:t.autoVacuum;this._options=Object.assign(Object.assign(Object.assign({},v),t),{autoVacuum:e,searchOptions:Object.assign(Object.assign({},x),t.searchOptions||{}),autoSuggestOptions:Object.assign(Object.assign({},z),t.autoSuggestOptions||{})}),this._index=new c,this._documentCount=0,this._documentIds=new Map,this._idToShortId=new Map,this._fieldIds={},this._fieldLength=new Map,this._avgFieldLength=[],this._nextId=0,this._storedFields=new Map,this._dirtCount=0,this._currentVacuum=null,this._enqueuedVacuum=null,this._enqueuedVacuumConditions=I,this.addFields(this._options.fields)}add(t){const{extractField:e,tokenize:s,processTerm:i,fields:n,idField:o}=this._options,r=e(t,o);if(null==r)throw new Error(`MiniSearch: document does not have ID field "${o}"`);if(this._idToShortId.has(r))throw new Error(`MiniSearch: duplicate ID ${r}`);const c=this.addDocumentId(r);this.saveStoredFields(c,t);for(const o of n){const n=e(t,o);if(null==n)continue;const r=s(n.toString(),o),u=this._fieldIds[o],h=new Set(r).size;this.addFieldLength(c,u,this._documentCount-1,h);for(const t of r){const e=i(t,o);if(Array.isArray(e))for(const t of e)this.addTerm(u,c,t);else e&&this.addTerm(u,c,e)}}}addAll(t){for(const e of t)this.add(e)}addAllAsync(t,e={}){const{chunkSize:s=10}=e,i={chunk:[],promise:Promise.resolve()},{chunk:n,promise:o}=t.reduce((({chunk:t,promise:e},i,n)=>(t.push(i),(n+1)%s==0?{chunk:[],promise:e.then((()=>new Promise((t=>setTimeout(t,0))))).then((()=>this.addAll(t)))}:{chunk:t,promise:e})),i);return o.then((()=>this.addAll(n)))}remove(t){const{tokenize:e,processTerm:s,extractField:i,fields:n,idField:o}=this._options,r=i(t,o);if(null==r)throw new Error(`MiniSearch: document does not have ID field "${o}"`);const c=this._idToShortId.get(r);if(null==c)throw new Error(`MiniSearch: cannot remove document with ID ${r}: it is not in the index`);for(const o of n){const n=i(t,o);if(null==n)continue;const r=e(n.toString(),o),u=this._fieldIds[o],h=new Set(r).size;this.removeFieldLength(c,u,this._documentCount,h);for(const t of r){const e=s(t,o);if(Array.isArray(e))for(const t of e)this.removeTerm(u,c,t);else e&&this.removeTerm(u,c,e)}}this._storedFields.delete(c),this._documentIds.delete(c),this._idToShortId.delete(r),this._fieldLength.delete(c),this._documentCount-=1}removeAll(t){if(t)for(const e of t)this.remove(e);else{if(arguments.length>0)throw new Error("Expected documents to be present. Omit the argument to remove all documents.");this._index=new c,this._documentCount=0,this._documentIds=new Map,this._idToShortId=new Map,this._fieldLength=new Map,this._avgFieldLength=[],this._storedFields=new Map,this._nextId=0}}discard(t){const e=this._idToShortId.get(t);if(null==e)throw new Error(`MiniSearch: cannot discard document with ID ${t}: it is not in the index`);this._idToShortId.delete(t),this._documentIds.delete(e),this._storedFields.delete(e),(this._fieldLength.get(e)||[]).forEach(((t,s)=>{this.removeFieldLength(e,s,this._documentCount,t)})),this._fieldLength.delete(e),this._documentCount-=1,this._dirtCount+=1,this.maybeAutoVacuum()}maybeAutoVacuum(){if(!1===this._options.autoVacuum)return;const{minDirtFactor:t,minDirtCount:e,batchSize:s,batchWait:i}=this._options.autoVacuum;this.conditionalVacuum({batchSize:s,batchWait:i},{minDirtCount:e,minDirtFactor:t})}discardAll(t){const e=this._options.autoVacuum;try{this._options.autoVacuum=!1;for(const e of t)this.discard(e)}finally{this._options.autoVacuum=e}this.maybeAutoVacuum()}replace(t){const{idField:e,extractField:s}=this._options,i=s(t,e);this.discard(i),this.add(t)}vacuum(t={}){return this.conditionalVacuum(t)}conditionalVacuum(t,e){return this._currentVacuum?(this._enqueuedVacuumConditions=this._enqueuedVacuumConditions&&e,null!=this._enqueuedVacuum||(this._enqueuedVacuum=this._currentVacuum.then((()=>{const e=this._enqueuedVacuumConditions;return this._enqueuedVacuumConditions=I,this.performVacuuming(t,e)}))),this._enqueuedVacuum):!1===this.vacuumConditionsMet(e)?Promise.resolve():(this._currentVacuum=this.performVacuuming(t),this._currentVacuum)}performVacuuming(e,s){return t(this,void 0,void 0,(function*(){const t=this._dirtCount;if(this.vacuumConditionsMet(s)){const s=e.batchSize||S.batchSize,i=e.batchWait||S.batchWait;let n=1;for(const[t,e]of this._index){for(const[t,s]of e)for(const[i]of s)this._documentIds.has(i)||(s.size<=1?e.delete(t):s.delete(i));0===this._index.get(t).size&&this._index.delete(t),n%s==0&&(yield new Promise((t=>setTimeout(t,i)))),n+=1}this._dirtCount-=t}yield null,this._currentVacuum=this._enqueuedVacuum,this._enqueuedVacuum=null}))}vacuumConditionsMet(t){if(null==t)return!0;let{minDirtCount:e,minDirtFactor:s}=t;return e=e||O.minDirtCount,s=s||O.minDirtFactor,this.dirtCount>=e&&this.dirtFactor>=s}get isVacuuming(){return null!=this._currentVacuum}get dirtCount(){return this._dirtCount}get dirtFactor(){return this._dirtCount/(1+this._documentCount+this._dirtCount)}has(t){return this._idToShortId.has(t)}getStoredFields(t){const e=this._idToShortId.get(t);if(null!=e)return this._storedFields.get(e)}search(t,e={}){const{searchOptions:s}=this._options,i=Object.assign(Object.assign({},s),e),n=this.executeQuery(t,e),o=[];for(const[t,{score:e,terms:s,match:r}]of n){const n=s.length||1,c={id:this._documentIds.get(t),score:e*n,terms:Object.keys(r),queryTerms:s,match:r};Object.assign(c,this._storedFields.get(t)),(null==i.filter||i.filter(c))&&o.push(c)}return t===_.wildcard&&null==i.boostDocument||o.sort(k),o}autoSuggest(t,e={}){e=Object.assign(Object.assign({},this._options.autoSuggestOptions),e);const s=new Map;for(const{score:i,terms:n}of this.search(t,e)){const t=n.join(" "),e=s.get(t);null!=e?(e.score+=i,e.count+=1):s.set(t,{score:i,terms:n,count:1})}const i=[];for(const[t,{score:e,terms:n,count:o}]of s)i.push({suggestion:t,terms:n,score:e/o});return i.sort(k),i}get documentCount(){return this._documentCount}get termCount(){return this._index.size}static loadJSON(t,e){if(null==e)throw new Error("MiniSearch: loadJSON should be given the same options used when serializing the index");return this.loadJS(JSON.parse(t),e)}static loadJSONAsync(e,s){return t(this,void 0,void 0,(function*(){if(null==s)throw new Error("MiniSearch: loadJSON should be given the same options used when serializing the index");return this.loadJSAsync(JSON.parse(e),s)}))}static getDefault(t){if(v.hasOwnProperty(t))return p(v,t);throw new Error(`MiniSearch: unknown option "${t}"`)}static loadJS(t,e){const{index:s,documentIds:i,fieldLength:n,storedFields:o,serializationVersion:r}=t,c=this.instantiateMiniSearch(t,e);c._documentIds=j(i),c._fieldLength=j(n),c._storedFields=j(o);for(const[t,e]of c._documentIds)c._idToShortId.set(e,t);for(const[t,e]of s){const s=new Map;for(const t of Object.keys(e)){let i=e[t];1===r&&(i=i.ds),s.set(parseInt(t,10),j(i))}c._index.set(t,s)}return c}static loadJSAsync(e,s){return t(this,void 0,void 0,(function*(){const{index:t,documentIds:i,fieldLength:n,storedFields:o,serializationVersion:r}=e,c=this.instantiateMiniSearch(e,s);c._documentIds=yield V(i),c._fieldLength=yield V(n),c._storedFields=yield V(o);for(const[t,e]of c._documentIds)c._idToShortId.set(e,t);let u=0;for(const[e,s]of t){const t=new Map;for(const e of Object.keys(s)){let i=s[e];1===r&&(i=i.ds),t.set(parseInt(e,10),yield V(i))}++u%1e3==0&&(yield T(0)),c._index.set(e,t)}return c}))}static instantiateMiniSearch(t,e){const{documentCount:s,nextId:i,fieldIds:n,averageFieldLength:o,dirtCount:r,serializationVersion:u}=t;if(1!==u&&2!==u)throw new Error("MiniSearch: cannot deserialize an index created with an incompatible version");const h=new _(e);return h._documentCount=s,h._nextId=i,h._idToShortId=new Map,h._fieldIds=n,h._avgFieldLength=o,h._dirtCount=r||0,h._index=new c,h}executeQuery(t,e={}){if(t===_.wildcard)return this.executeWildcardQuery(e);if("string"!=typeof t){const s=Object.assign(Object.assign(Object.assign({},e),t),{queries:void 0}),i=t.queries.map((t=>this.executeQuery(t,s)));return this.combineResults(i,s.combineWith)}const{tokenize:s,processTerm:i,searchOptions:n}=this._options,o=Object.assign(Object.assign({tokenize:s,processTerm:i},n),e),{tokenize:r,processTerm:c}=o,u=r(t).flatMap((t=>c(t))).filter((t=>!!t)).map(b(o)).map((t=>this.executeQuerySpec(t,o)));return this.combineResults(u,o.combineWith)}executeQuerySpec(t,e){const s=Object.assign(Object.assign({},this._options.searchOptions),e),i=(s.fields||this._options.fields).reduce(((t,e)=>Object.assign(Object.assign({},t),{[e]:p(s.boost,e)||1})),{}),{boostDocument:n,weights:o,maxFuzzy:r,bm25:c}=s,{fuzzy:u,prefix:h}=Object.assign(Object.assign({},x.weights),o),d=this._index.get(t.term),a=this.termResults(t.term,t.term,1,t.termBoost,d,i,n,c);let l,f;if(t.prefix&&(l=this._index.atPrefix(t.term)),t.fuzzy){const e=!0===t.fuzzy?.2:t.fuzzy,s=e<1?Math.min(r,Math.round(t.term.length*e)):e;s&&(f=this._index.fuzzyGet(t.term,s))}if(l)for(const[e,s]of l){const o=e.length-t.term.length;if(!o)continue;null==f||f.delete(e);const r=h*e.length/(e.length+.3*o);this.termResults(t.term,e,r,t.termBoost,s,i,n,c,a)}if(f)for(const e of f.keys()){const[s,o]=f.get(e);if(!o)continue;const r=u*e.length/(e.length+o);this.termResults(t.term,e,r,t.termBoost,s,i,n,c,a)}return a}executeWildcardQuery(t){const e=new Map,s=Object.assign(Object.assign({},this._options.searchOptions),t);for(const[t,i]of this._documentIds){const n=s.boostDocument?s.boostDocument(i,"",this._storedFields.get(t)):1;e.set(t,{score:n,terms:[],match:{}})}return e}combineResults(t,e=g){if(0===t.length)return new Map;const s=e.toLowerCase(),i=y[s];if(!i)throw new Error(`Invalid combination operator: ${e}`);return t.reduce(i)||new Map}toJSON(){const t=[];for(const[e,s]of this._index){const i={};for(const[t,e]of s)i[t]=Object.fromEntries(e);t.push([e,i])}return{documentCount:this._documentCount,nextId:this._nextId,documentIds:Object.fromEntries(this._documentIds),fieldIds:this._fieldIds,fieldLength:Object.fromEntries(this._fieldLength),averageFieldLength:this._avgFieldLength,storedFields:Object.fromEntries(this._storedFields),dirtCount:this._dirtCount,index:t,serializationVersion:2}}termResults(t,e,s,i,n,o,r,c,u=new Map){if(null==n)return u;for(const h of Object.keys(o)){const d=o[h],a=this._fieldIds[h],l=n.get(a);if(null==l)continue;let f=l.size;const m=this._avgFieldLength[a];for(const n of l.keys()){if(!this._documentIds.has(n)){this.removeTerm(a,n,e),f-=1;continue}const o=r?r(this._documentIds.get(n),e,this._storedFields.get(n)):1;if(!o)continue;const g=l.get(n),_=this._fieldLength.get(n)[a],y=s*i*d*o*w(g,f,this._documentCount,_,m,c),b=u.get(n);if(b){b.score+=y,F(b.terms,t);const s=p(b.match,e);s?s.push(h):b.match[e]=[h]}else u.set(n,{score:y,terms:[t],match:{[e]:[h]}})}}return u}addTerm(t,e,s){const i=this._index.fetch(s,C);let n=i.get(t);if(null==n)n=new Map,n.set(e,1),i.set(t,n);else{const t=n.get(e);n.set(e,(t||0)+1)}}removeTerm(t,e,s){if(!this._index.has(s))return void this.warnDocumentChanged(e,t,s);const i=this._index.fetch(s,C),n=i.get(t);null==n||null==n.get(e)?this.warnDocumentChanged(e,t,s):n.get(e)<=1?n.size<=1?i.delete(t):n.delete(e):n.set(e,n.get(e)-1),0===this._index.get(s).size&&this._index.delete(s)}warnDocumentChanged(t,e,s){for(const i of Object.keys(this._fieldIds))if(this._fieldIds[i]===e)return void this._options.logger("warn",`MiniSearch: document with ID ${this._documentIds.get(t)} has changed before removal: term "${s}" was not present in field "${i}". Removing a document after it has changed can corrupt the index!`,"version_conflict")}addDocumentId(t){const e=this._nextId;return this._idToShortId.set(t,e),this._documentIds.set(e,t),this._documentCount+=1,this._nextId+=1,e}addFields(t){for(let e=0;e<t.length;e++)this._fieldIds[t[e]]=e}addFieldLength(t,e,s,i){let n=this._fieldLength.get(t);null==n&&this._fieldLength.set(t,n=[]),n[e]=i;const o=(this._avgFieldLength[e]||0)*s+i;this._avgFieldLength[e]=o/(s+1)}removeFieldLength(t,e,s,i){if(1===s)return void(this._avgFieldLength[e]=0);const n=this._avgFieldLength[e]*s-i;this._avgFieldLength[e]=n/(s-1)}saveStoredFields(t,e){const{storeFields:s,extractField:i}=this._options;if(null==s||0===s.length)return;let n=this._storedFields.get(t);null==n&&this._storedFields.set(t,n={});for(const t of s){const s=i(e,t);void 0!==s&&(n[t]=s)}}}_.wildcard=Symbol("*");const p=(t,e)=>Object.prototype.hasOwnProperty.call(t,e)?t[e]:void 0,y={[g]:(t,e)=>{for(const s of e.keys()){const i=t.get(s);if(null==i)t.set(s,e.get(s));else{const{score:t,terms:n,match:o}=e.get(s);i.score=i.score+t,i.match=Object.assign(i.match,o),M(i.terms,n)}}return t},and:(t,e)=>{const s=new Map;for(const i of e.keys()){const n=t.get(i);if(null==n)continue;const{score:o,terms:r,match:c}=e.get(i);M(n.terms,r),s.set(i,{score:n.score+o,terms:n.terms,match:Object.assign(n.match,c)})}return s},and_not:(t,e)=>{for(const s of e.keys())t.delete(s);return t}},w=(t,e,s,i,n,o)=>{const{k:r,b:c,d:u}=o;return Math.log(1+(s-e+.5)/(e+.5))*(u+t*(r+1)/(t+r*(1-c+c*i/n)))},b=t=>(e,s,i)=>({term:e,fuzzy:"function"==typeof t.fuzzy?t.fuzzy(e,s,i):t.fuzzy||!1,prefix:"function"==typeof t.prefix?t.prefix(e,s,i):!0===t.prefix,termBoost:"function"==typeof t.boostTerm?t.boostTerm(e,s,i):1}),v={idField:"id",extractField:(t,e)=>t[e],tokenize:t=>t.split(L),processTerm:t=>t.toLowerCase(),fields:void 0,searchOptions:void 0,storeFields:[],logger:(t,e)=>{"function"==typeof(null===console||void 0===console?void 0:console[t])&&console[t](e)},autoVacuum:!0},x={combineWith:g,prefix:!1,fuzzy:!1,maxFuzzy:6,boost:{},weights:{fuzzy:.45,prefix:.375},bm25:{k:1.2,b:.7,d:.5}},z={combineWith:"and",prefix:(t,e,s)=>e===s.length-1},S={batchSize:1e3,batchWait:10},I={minDirtFactor:.1,minDirtCount:20},O=Object.assign(Object.assign({},S),I),F=(t,e)=>{t.includes(e)||t.push(e)},M=(t,e)=>{for(const s of e)t.includes(s)||t.push(s)},k=({score:t},{score:e})=>e-t,C=()=>new Map,j=t=>{const e=new Map;for(const s of Object.keys(t))e.set(parseInt(s,10),t[s]);return e},V=e=>t(void 0,void 0,void 0,(function*(){const t=new Map;let s=0;for(const i of Object.keys(e))t.set(parseInt(i,10),e[i]),++s%1e3==0&&(yield T(0));return t})),T=t=>new Promise((e=>setTimeout(e,t))),L=/[\n\r\p{Z}\p{P}]+/u;return _}));
|
|
8
|
+
//# sourceMappingURL=/sm/0f05ede3003a11c0848176daa6dae791d4aa6c5c93da9e99ae929f75084ce0d0.map
|
|
@@ -10,6 +10,29 @@ module Markdowndocs
|
|
|
10
10
|
|
|
11
11
|
def index
|
|
12
12
|
@docs_by_category = Documentation.grouped_by_category
|
|
13
|
+
@search_enabled = Markdowndocs.config.search_enabled
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def search_index
|
|
17
|
+
unless Markdowndocs.config.search_enabled
|
|
18
|
+
render_not_found
|
|
19
|
+
return
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
cache_key = "markdowndocs:search_index:#{Documentation.all.map(&:cache_key).join(",")}"
|
|
23
|
+
json = Rails.cache.fetch(cache_key, expires_in: Markdowndocs.config.cache_expiry) do
|
|
24
|
+
Documentation.all.map do |doc|
|
|
25
|
+
{
|
|
26
|
+
id: doc.slug,
|
|
27
|
+
title: doc.title,
|
|
28
|
+
description: doc.description,
|
|
29
|
+
content: doc.plain_text_content
|
|
30
|
+
}
|
|
31
|
+
end.to_json
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
response.headers["Cache-Control"] = "public, max-age=#{Markdowndocs.config.cache_expiry.to_i}"
|
|
35
|
+
render json: json
|
|
13
36
|
end
|
|
14
37
|
|
|
15
38
|
def show
|
|
@@ -83,6 +83,21 @@ module Markdowndocs
|
|
|
83
83
|
available_modes.include?(mode.to_s)
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
# Returns content stripped of frontmatter, markdown syntax, and HTML tags
|
|
87
|
+
# for use in search indexing.
|
|
88
|
+
def plain_text_content
|
|
89
|
+
parsed = parse_frontmatter
|
|
90
|
+
text = parsed[:markdown]
|
|
91
|
+
text = text.gsub(/^#+\s*/, "") # headings
|
|
92
|
+
text = text.gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') # links
|
|
93
|
+
text = text.gsub(/[*_~`]/, "") # emphasis markers
|
|
94
|
+
text = text.gsub(/```[\s\S]*?```/, "") # fenced code blocks
|
|
95
|
+
text = text.gsub(/<[^>]+>/, "") # HTML tags
|
|
96
|
+
text = text.gsub(/^\s*[-*+]\s/, "") # list markers
|
|
97
|
+
text = text.gsub(/\n{2,}/, "\n") # collapse blank lines
|
|
98
|
+
text.strip
|
|
99
|
+
end
|
|
100
|
+
|
|
86
101
|
private
|
|
87
102
|
|
|
88
103
|
def derive_slug
|
|
@@ -1,24 +1,61 @@
|
|
|
1
1
|
<div class="min-h-screen bg-gray-50">
|
|
2
2
|
<div class="max-w-7xl mx-auto px-4 py-12
|
|
3
3
|
sm:px-6
|
|
4
|
-
lg:px-8"
|
|
4
|
+
lg:px-8"
|
|
5
|
+
<% if @search_enabled %>
|
|
6
|
+
data-controller="docs-search"
|
|
7
|
+
data-docs-search-index-url-value="<%= markdowndocs.search_index_path %>"
|
|
8
|
+
<% end %>>
|
|
5
9
|
<!-- Hero Section -->
|
|
6
10
|
<div class="text-center mb-12">
|
|
7
11
|
<h1 class="text-4xl font-bold text-gray-900 mb-4">Documentation</h1>
|
|
8
12
|
<p class="text-xl text-gray-600">Browse our comprehensive documentation and guides</p>
|
|
13
|
+
|
|
14
|
+
<% if @search_enabled %>
|
|
15
|
+
<!-- Search input -->
|
|
16
|
+
<div class="mt-6 max-w-lg mx-auto">
|
|
17
|
+
<div class="relative">
|
|
18
|
+
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
19
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
20
|
+
</svg>
|
|
21
|
+
<input
|
|
22
|
+
type="text"
|
|
23
|
+
data-docs-search-target="input"
|
|
24
|
+
data-action="input->docs-search#search"
|
|
25
|
+
placeholder="<%= t("markdowndocs.search_placeholder", default: "Search documentation...") %>"
|
|
26
|
+
class="w-full pl-10 pr-4 py-3 rounded-lg border border-gray-300 shadow-sm text-gray-900 placeholder-gray-400 transition-colors
|
|
27
|
+
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
28
|
+
autocomplete="off" />
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<% end %>
|
|
9
32
|
</div>
|
|
10
33
|
|
|
11
34
|
<% if @docs_by_category.values.flatten.any? %>
|
|
35
|
+
<!-- No results message -->
|
|
36
|
+
<% if @search_enabled %>
|
|
37
|
+
<div data-docs-search-target="noResults" class="hidden text-center py-12">
|
|
38
|
+
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
39
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
40
|
+
</svg>
|
|
41
|
+
<h3 class="mt-2 text-sm font-medium text-gray-900"><%= t("markdowndocs.no_search_results", default: "No matching documents") %></h3>
|
|
42
|
+
<p class="mt-1 text-sm text-gray-600"><%= t("markdowndocs.try_different_search", default: "Try a different search term") %></p>
|
|
43
|
+
</div>
|
|
44
|
+
<% end %>
|
|
45
|
+
|
|
12
46
|
<!-- Documentation Cards by Category -->
|
|
13
47
|
<% @docs_by_category.each do |category, docs| %>
|
|
14
48
|
<% if docs.any? %>
|
|
15
|
-
<div id="<%= category.parameterize %>" class="mb-12 scroll-mt-6"
|
|
49
|
+
<div id="<%= category.parameterize %>" class="mb-12 scroll-mt-6"
|
|
50
|
+
<% if @search_enabled %>data-docs-search-target="category"<% end %>>
|
|
16
51
|
<h2 class="text-2xl font-semibold text-gray-900 mb-6"><%= category %></h2>
|
|
17
52
|
<div class="grid grid-cols-1 gap-6
|
|
18
53
|
md:grid-cols-2
|
|
19
54
|
lg:grid-cols-3">
|
|
20
55
|
<% docs.each do |doc| %>
|
|
21
|
-
|
|
56
|
+
<div <% if @search_enabled %>data-docs-search-target="card" data-slug="<%= doc.slug %>"<% end %>>
|
|
57
|
+
<%= render "markdowndocs/docs/card", doc: doc %>
|
|
58
|
+
</div>
|
|
22
59
|
<% end %>
|
|
23
60
|
</div>
|
|
24
61
|
</div>
|
data/config/routes.rb
CHANGED
|
@@ -24,6 +24,10 @@ Markdowndocs.configure do |config|
|
|
|
24
24
|
# Cache expiry for rendered markdown (default: 1.hour)
|
|
25
25
|
# config.cache_expiry = 1.hour
|
|
26
26
|
|
|
27
|
+
# Enable full-text search on the documentation index (default: false)
|
|
28
|
+
# Adds a search bar that filters docs by title, description, and content
|
|
29
|
+
# config.search_enabled = true
|
|
30
|
+
|
|
27
31
|
# Optional: Resolve current user's mode preference from database
|
|
28
32
|
# Return nil to fall back to cookie/default
|
|
29
33
|
# config.user_mode_resolver = ->(controller) {
|
|
@@ -4,7 +4,7 @@ module Markdowndocs
|
|
|
4
4
|
class Configuration
|
|
5
5
|
attr_accessor :docs_path, :categories, :modes, :default_mode,
|
|
6
6
|
:markdown_options, :rouge_theme, :cache_expiry,
|
|
7
|
-
:user_mode_resolver, :user_mode_saver
|
|
7
|
+
:user_mode_resolver, :user_mode_saver, :search_enabled
|
|
8
8
|
|
|
9
9
|
def initialize
|
|
10
10
|
@docs_path = nil # Resolved lazily so Rails.root is available
|
|
@@ -16,6 +16,7 @@ module Markdowndocs
|
|
|
16
16
|
@cache_expiry = 1.hour
|
|
17
17
|
@user_mode_resolver = nil
|
|
18
18
|
@user_mode_saver = nil
|
|
19
|
+
@search_enabled = false
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
# Lazily resolve docs_path so Rails.root is available
|
data/lib/markdowndocs/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: markdowndocs
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dave Chmura
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-02-
|
|
11
|
+
date: 2026-02-21 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -79,6 +79,8 @@ files:
|
|
|
79
79
|
- README.md
|
|
80
80
|
- Rakefile
|
|
81
81
|
- app/assets/javascripts/markdowndocs/controllers/docs_mode_controller.js
|
|
82
|
+
- app/assets/javascripts/markdowndocs/controllers/docs_search_controller.js
|
|
83
|
+
- app/assets/javascripts/markdowndocs/vendor/minisearch.min.js
|
|
82
84
|
- app/controllers/markdowndocs/application_controller.rb
|
|
83
85
|
- app/controllers/markdowndocs/docs_controller.rb
|
|
84
86
|
- app/controllers/markdowndocs/preferences_controller.rb
|