Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@docmd/plugin-search

Package Overview
Dependencies
Maintainers
1
Versions
42
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@docmd/plugin-search - npm Package Compare versions

Comparing version
0.6.9
to
0.7.0
+10
i18n/en.json
{
"searchPlaceholder": "Search documentation...",
"searchNoResults": "No results found.",
"searchError": "Failed to load search index.",
"searchInitial": "Type to start searching...",
"searchClose": "Close search",
"searchNavigate": "to navigate",
"searchEscape": "to close",
"searchVersionBadge": "v{version}"
}
{
"searchPlaceholder": "दस्तावेज़ खोजें...",
"searchNoResults": "कोई परिणाम नहीं मिला।",
"searchError": "खोज अनुक्रमणिका लोड करने में विफल।",
"searchInitial": "खोजने के लिए टाइप करें...",
"searchClose": "खोज बंद करें",
"searchNavigate": "नेविगेट करें",
"searchEscape": "बंद करें",
"searchVersionBadge": "v{version}"
}
{
"searchPlaceholder": "搜索文档...",
"searchNoResults": "未找到结果。",
"searchError": "无法加载搜索索引。",
"searchInitial": "输入开始搜索...",
"searchClose": "关闭搜索",
"searchNavigate": "导航",
"searchEscape": "关闭",
"searchVersionBadge": "v{version}"
}
+6
-6

@@ -1,9 +0,9 @@

(function(){let g=null,h=!1,n=-1;function E(){let a=document.getElementById("docmd-search-modal"),c=document.getElementById("docmd-search-input"),s=document.getElementById("docmd-search-results");if(!a||!c||!s)return;let T=window.DOCMD_SITE_ROOT||window.DOCMD_ROOT||"./",f=new URL(T,window.location.href).href;f.endsWith("/")||(f+="/");let y='<div class="search-initial">Type to start searching...</div>';function w(){a.style.display="flex",window.lastFocusedElement=document.activeElement,setTimeout(()=>c.focus(),50),c.value.trim()||(s.innerHTML=y,n=-1),h||M()}function d(){a.style.display="none",window.lastFocusedElement&&window.lastFocusedElement.focus(),n=-1}document.addEventListener("click",e=>{let t=e.target;t?.closest(".docmd-search-trigger")&&(e.preventDefault(),w()),(t===a||t?.closest(".docmd-search-close"))&&d()}),document.addEventListener("keydown",e=>{if((e.metaKey||e.ctrlKey)&&e.key==="k"&&(e.preventDefault(),a.style.display==="flex"?d():w()),a.style.display==="flex"){let t=s.querySelectorAll(".search-result-item");e.key==="Escape"?(e.preventDefault(),d()):e.key==="ArrowDown"?(e.preventDefault(),t.length&&(n=(n+1)%t.length,p(t))):e.key==="ArrowUp"?(e.preventDefault(),t.length&&(n=(n-1+t.length)%t.length,p(t))):e.key==="Enter"&&(e.preventDefault(),n>=0&&t[n]?t[n].click():t.length>0&&t[0].click())}});function p(e){e.forEach((t,i)=>{t.classList.toggle("selected",i===n),i===n&&t.scrollIntoView({block:"nearest"})})}async function M(){try{let e=`${f}search-index.json`,t=await fetch(e);if(t.headers.get("content-type")?.includes("text/html"))throw new Error("Invalid content type");if(!t.ok)throw new Error(String(t.status));let i=await t.text();g=MiniSearch.loadJSON(i,{fields:["title","headings","text"],storeFields:["title","id","text"],searchOptions:{fuzzy:.2,prefix:!0,boost:{title:2,headings:1.5}}}),h=!0,c.value.trim()&&c.dispatchEvent(new Event("input"))}catch{s.innerHTML='<div class="search-error">Failed to load search index.</div>'}}function O(e,t){if(!e)return"";let i=t.split(/\s+/).filter(u=>u.length>2),r=-1;for(let u of i){let L=e.toLowerCase().indexOf(u.toLowerCase());if(L>=0){r=L;break}}let o=Math.max(0,r-60),m=Math.min(e.length,r+60),l=e.substring(o,m);o>0&&(l="..."+l),m<e.length&&(l+="...");let v=i.map(u=>u.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("|");return v&&(l=l.replace(new RegExp(`(${v})`,"gi"),"<mark>$1</mark>")),l}c.addEventListener("input",e=>{let t=e.target.value.trim();if(n=-1,!t){s.innerHTML=y;return}if(!h)return;let i=g.search(t);if(i.length===0){s.innerHTML='<div class="search-no-results">No results found.</div>';return}s.innerHTML=i.slice(0,10).map((r,o)=>{let m=O(r.text,t);return`
<a href="${`${f}${r.id}`}" class="search-result-item" data-index="${o}">
<div class="search-result-title">${r.title}</div>
<div class="search-result-preview">${m}</div>
</a>`}).join(""),s.querySelectorAll(".search-result-item").forEach((r,o)=>{r.addEventListener("mouseenter",()=>{n=o,p(s.querySelectorAll(".search-result-item"))})})}),s.addEventListener("click",e=>{e.target.closest(".search-result-item")&&d()}),window.closeDocmdSearch=d}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",E):E()})();
(function(){let L=null,m=!1,s=-1;function T(){let l=document.getElementById("docmd-search-modal"),c=document.getElementById("docmd-search-input"),r=document.getElementById("docmd-search-results");if(!l||!c||!r)return;let g={initial:l.dataset.searchInitial||"Type to start searching...",noResults:l.dataset.searchNoResults||"No results found.",error:l.dataset.searchError||"Failed to load search index."},$=window.DOCMD_SITE_ROOT||window.DOCMD_ROOT||"./",p=new URL($,window.location.href).href;p.endsWith("/")||(p+="/");let w=(window.DOCMD_SITE_ROOT||window.DOCMD_ROOT||"/").replace(/\/$/,"")+"/",v=window.location.pathname,M=(v.startsWith(w)?v.slice(w.length):v.replace(/^\//,"")).split("/")[0],x=document.querySelectorAll("link[hreflang]"),O=new Set;x.forEach(e=>{let t=e.getAttribute("hreflang");t&&t!=="x-default"&&O.add(t)});let H=O.has(M)?M+"/":"",I=new URL(w,window.location.href).href+H+"search-index.json",S=`<div class="search-initial">${g.initial}</div>`;function D(){l.style.display="flex",window.lastFocusedElement=document.activeElement,setTimeout(()=>c.focus(),50),c.value.trim()||(r.innerHTML=S,s=-1),m||R()}function f(){l.style.display="none",window.lastFocusedElement&&window.lastFocusedElement.focus(),s=-1}document.addEventListener("click",e=>{let t=e.target;t?.closest(".docmd-search-trigger")&&(e.preventDefault(),D()),(t===l||t?.closest(".docmd-search-close"))&&f()}),document.addEventListener("keydown",e=>{if((e.metaKey||e.ctrlKey)&&e.key==="k"&&(e.preventDefault(),l.style.display==="flex"?f():D()),l.style.display==="flex"){let t=r.querySelectorAll(".search-result-item");e.key==="Escape"?(e.preventDefault(),f()):e.key==="ArrowDown"?(e.preventDefault(),t.length&&(s=(s+1)%t.length,E(t))):e.key==="ArrowUp"?(e.preventDefault(),t.length&&(s=(s-1+t.length)%t.length,E(t))):e.key==="Enter"&&(e.preventDefault(),s>=0&&t[s]?t[s].click():t.length>0&&t[0].click())}});function E(e){e.forEach((t,a)=>{t.classList.toggle("selected",a===s),a===s&&t.scrollIntoView({block:"nearest"})})}async function R(){try{let e=await fetch(I);if(e.headers.get("content-type")?.includes("text/html"))throw new Error("Invalid content type");if(!e.ok)throw new Error(String(e.status));let t=await e.text();L=MiniSearch.loadJSON(t,{fields:["title","headings","text"],storeFields:["title","id","text","version"],searchOptions:{fuzzy:.2,prefix:!0,boost:{title:2,headings:1.5}}}),m=!0,c.value.trim()&&c.dispatchEvent(new Event("input"))}catch{r.innerHTML=`<div class="search-error">${g.error}</div>`}}function b(e,t){if(!e)return"";let a=t.split(/\s+/).filter(i=>i.length>2),d=-1;for(let i of a){let u=e.toLowerCase().indexOf(i.toLowerCase());if(u>=0){d=u;break}}let h=Math.max(0,d-60),y=Math.min(e.length,d+60),n=e.substring(h,y);h>0&&(n="..."+n),y<e.length&&(n+="...");let o=a.map(i=>i.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("|");return o&&(n=n.replace(new RegExp(`(${o})`,"gi"),"<mark>$1</mark>")),n}c.addEventListener("input",e=>{let t=e.target.value.trim();if(s=-1,!t){r.innerHTML=S;return}if(!m)return;let a=L.search(t);if(a.length===0){r.innerHTML=`<div class="search-no-results">${g.noResults}</div>`;return}let d={},h=[210,150,30,330,270,60,180,0];[...new Set(a.map(n=>n.version).filter(Boolean))].forEach((n,o)=>{let i=h[o%h.length];d[n]={bg:`hsl(${i}, 55%, 92%)`,fg:`hsl(${i}, 60%, 35%)`}}),r.innerHTML=a.slice(0,10).map((n,o)=>{let i=b(n.text,t),u=`${p}${n.id}`,k=n.version?d[n.version]:null,A=n.version?`<span class="search-result-version" style="background:${k.bg};color:${k.fg}">${n.version}</span>`:"";return`
<a href="${u}" class="search-result-item" data-index="${o}">
<div class="search-result-title">${n.title}${A}</div>
<div class="search-result-preview">${i}</div>
</a>`}).join(""),r.querySelectorAll(".search-result-item").forEach((n,o)=>{n.addEventListener("mouseenter",()=>{s=o,E(r.querySelectorAll(".search-result-item"))})})}),r.addEventListener("click",e=>{e.target.closest(".search-result-item")&&f()}),window.closeDocmdSearch=f}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",T):T()})();
/**
* --------------------------------------------------------------------
* docmd : the minimalist, zero-config documentation generator.
* docmd : the zero-config documentation engine.
*

@@ -10,0 +10,0 @@ * @package @docmd/core (and ecosystem)

/**
* --------------------------------------------------------------------
* docmd : the minimalist, zero-config documentation generator.
* docmd : the zero-config documentation engine.
*

@@ -14,3 +14,18 @@ * @package @docmd/core (and ecosystem)

*/
/**
* Plugin translations hook — called by the engine for each locale.
* Returns search-specific UI strings keyed by locale.
*/
export declare function translations(localeId: string): Record<string, string>;
/**
* Post-build hook — generates per-locale search indexes.
* Each locale gets its own `search-index.json` covering all versions within that locale.
* Default locale index is at root, non-default locale indexes are at `/{locale}/search-index.json`.
*/
export declare function onPostBuild({ config, pages, outputDir, log }: any): Promise<void>;
/**
* Inject the search modal HTML.
* Strings are passed as data attributes so the client JS can read them
* regardless of locale — the engine merges plugin translations before render.
*/
export declare function generateScripts(config: any): {

@@ -17,0 +32,0 @@ bodyScriptsHtml?: undefined;

/**
* --------------------------------------------------------------------
* docmd : the minimalist, zero-config documentation generator.
* docmd : the zero-config documentation engine.
*

@@ -16,2 +16,3 @@ * @package @docmd/core (and ecosystem)

import fs from 'fs/promises';
import nativeFs from 'fs';
import MiniSearch from 'minisearch';

@@ -21,4 +22,40 @@ import { fileURLToPath } from 'url';

const __dirname = path.dirname(__filename);
// Resolve i18n directory (sibling to dist/ in the package)
const i18nDir = path.resolve(__dirname, '..', 'i18n');
/**
* Load translation strings for a given locale.
* Falls back to English if the locale file doesn't exist.
*/
function loadPluginStrings(localeId) {
try {
// Try locale-specific file
const localePath = path.join(i18nDir, `${localeId}.json`);
if (nativeFs.existsSync(localePath)) {
return JSON.parse(nativeFs.readFileSync(localePath, 'utf8'));
}
}
catch { /* fallback below */ }
// Fallback to English
try {
const enPath = path.join(i18nDir, 'en.json');
if (nativeFs.existsSync(enPath)) {
return JSON.parse(nativeFs.readFileSync(enPath, 'utf8'));
}
}
catch { /* silent */ }
return {};
}
/**
* Plugin translations hook — called by the engine for each locale.
* Returns search-specific UI strings keyed by locale.
*/
export function translations(localeId) {
return loadPluginStrings(localeId || 'en');
}
/**
* Post-build hook — generates per-locale search indexes.
* Each locale gets its own `search-index.json` covering all versions within that locale.
* Default locale index is at root, non-default locale indexes are at `/{locale}/search-index.json`.
*/
export async function onPostBuild({ config, pages, outputDir, log }) {
// Check if disabled in new config schema or old config schema
const isEnabled = config.optionsMenu ? config.optionsMenu.components.search !== false : config.search !== false;

@@ -29,6 +66,36 @@ if (!isEnabled)

log('🔍 Generating search index...');
const searchData = [];
const seenIds = new Set();
pages.forEach(page => {
if (page.searchData) {
// Determine locale configuration
const locales = config.i18n?.locales || [];
const defaultLocale = config.i18n?.default || null;
const hasVersioning = config.versions?.all?.length > 0;
const currentVersionId = config.versions?.current;
// Group pages by locale
const localePages = { '_default': [] };
for (const loc of locales) {
if (loc.id !== defaultLocale) {
localePages[loc.id] = [];
}
}
for (const page of pages) {
if (!page.searchData)
continue;
const outputPath = page.outputPath.replace(/\\/g, '/');
// Determine which locale this page belongs to
let localeId = '_default';
for (const loc of locales) {
if (loc.id !== defaultLocale && outputPath.startsWith(loc.id + '/')) {
localeId = loc.id;
break;
}
}
localePages[localeId] = localePages[localeId] || [];
localePages[localeId].push(page);
}
// Build an index per locale
for (const [localeId, locPages] of Object.entries(localePages)) {
if (locPages.length === 0)
continue;
const searchData = [];
const seenIds = new Set();
for (const page of locPages) {
let pageId = page.outputPath.replace(/\\/g, '/');

@@ -39,16 +106,36 @@ if (pageId.endsWith('/index.html'))

pageId = pageId.slice(0, -5);
// 1. Add the main page record
// Detect version from the output path
let version = null;
if (hasVersioning && config.versions?.all) {
for (const v of config.versions.all) {
// Check for version prefix: `hi/05/page` or `05/page`
const stripped = localeId !== '_default' ? pageId.replace(new RegExp(`^${localeId}/`), '') : pageId;
if (stripped.startsWith(v.id + '/') || stripped === v.id) {
version = v.label || v.id;
break;
}
}
// Current version pages have no prefix — tag them with the current label
if (!version) {
const currentVersion = config.versions.all.find((v) => v.id === currentVersionId);
if (currentVersion)
version = currentVersion.label || currentVersion.id;
}
}
// Add the main page record
if (!seenIds.has(pageId)) {
seenIds.add(pageId);
searchData.push({
const entry = {
id: pageId,
title: page.searchData.title,
text: page.searchData.content,
// We can keep page-level headings as a string block for general page matching
headings: (page.searchData.headings || []).map(h => h.text).join(' ')
});
headings: (page.searchData.headings || []).map((h) => h.text).join(' ')
};
if (hasVersioning && version)
entry.version = version;
searchData.push(entry);
}
// 2. Add individual heading records for deep linking
// Add individual heading records for deep linking
if (page.searchData.headings && Array.isArray(page.searchData.headings)) {
page.searchData.headings.forEach(heading => {
for (const heading of page.searchData.headings) {
if (heading.id && heading.text) {

@@ -58,3 +145,3 @@ const hId = `${pageId}#${heading.id}`;

seenIds.add(hId);
searchData.push({
const entry = {
id: hId,

@@ -64,19 +151,35 @@ title: `${page.searchData.title} > ${heading.text}`,

headings: heading.text
});
};
if (hasVersioning && version)
entry.version = version;
searchData.push(entry);
}
}
});
}
}
}
});
const miniSearch = new MiniSearch({
fields: ['title', 'headings', 'text'],
storeFields: ['title', 'id', 'text'],
searchOptions: { boost: { title: 2, headings: 1.5 }, fuzzy: 0.2 }
});
miniSearch.addAll(searchData);
const json = JSON.stringify(miniSearch.toJSON());
await fs.writeFile(path.join(outputDir, 'search-index.json'), json);
// Build MiniSearch index
const storeFields = ['title', 'id', 'text'];
if (hasVersioning)
storeFields.push('version');
const miniSearch = new MiniSearch({
fields: ['title', 'headings', 'text'],
storeFields,
searchOptions: { boost: { title: 2, headings: 1.5 }, fuzzy: 0.2 }
});
miniSearch.addAll(searchData);
const json = JSON.stringify(miniSearch.toJSON());
// Write to the correct locale directory
const indexPath = localeId === '_default'
? path.join(outputDir, 'search-index.json')
: path.join(outputDir, localeId, 'search-index.json');
await fs.mkdir(path.dirname(indexPath), { recursive: true });
await fs.writeFile(indexPath, json);
}
}
// Inject the modal HTML only if the plugin is running
/**
* Inject the search modal HTML.
* Strings are passed as data attributes so the client JS can read them
* regardless of locale — the engine merges plugin translations before render.
*/
export function generateScripts(config) {

@@ -86,2 +189,5 @@ const isEnabled = config.optionsMenu ? config.optionsMenu.components.search !== false : config.search !== false;

return {};
// Load strings for the active locale (available at render time)
const localeId = config._activeLocale?.id || 'en';
const strings = loadPluginStrings(localeId);
const searchIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide-icon icon-search"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg>`;

@@ -91,8 +197,14 @@ const closeIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide-icon icon-x"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>`;

<!-- Search Modal (Injected by @docmd/plugin-search) -->
<div id="docmd-search-modal" class="docmd-search-modal" style="display: none;">
<div id="docmd-search-modal" class="docmd-search-modal" style="display: none;"
data-search-placeholder="${strings.searchPlaceholder || 'Search documentation...'}"
data-search-no-results="${strings.searchNoResults || 'No results found.'}"
data-search-error="${strings.searchError || 'Failed to load search index.'}"
data-search-initial="${strings.searchInitial || 'Type to start searching...'}"
data-search-navigate="${strings.searchNavigate || 'to navigate'}"
data-search-escape="${strings.searchEscape || 'to close'}">
<div class="docmd-search-box">
<div class="docmd-search-header">
${searchIcon}
<input type="text" id="docmd-search-input" placeholder="Search documentation..." autocomplete="off" spellcheck="false">
<button onclick="window.closeDocmdSearch()" class="docmd-search-close" aria-label="Close search">
<input type="text" id="docmd-search-input" placeholder="${strings.searchPlaceholder || 'Search documentation...'}" autocomplete="off" spellcheck="false">
<button onclick="window.closeDocmdSearch()" class="docmd-search-close" aria-label="${strings.searchClose || 'Close search'}">
${closeIcon}

@@ -103,4 +215,4 @@ </button>

<div class="docmd-search-footer">
<span><kbd class="docmd-kbd">↑</kbd> <kbd class="docmd-kbd">↓</kbd> to navigate</span>
<span><kbd class="docmd-kbd">ESC</kbd> to close</span>
<span><kbd class="docmd-kbd">↑</kbd> <kbd class="docmd-kbd">↓</kbd> ${strings.searchNavigate || 'to navigate'}</span>
<span><kbd class="docmd-kbd">ESC</kbd> ${strings.searchEscape || 'to close'}</span>
</div>

@@ -107,0 +219,0 @@ </div>

{
"name": "@docmd/plugin-search",
"version": "0.6.9",
"version": "0.7.0",
"description": "Offline full-text search for docmd.",

@@ -12,3 +12,4 @@ "type": "module",

"files": [
"dist"
"dist",
"i18n"
],

@@ -15,0 +16,0 @@ "dependencies": {

# @docmd/plugin-search
Adds offline, full-text search to **docmd** sites.
Offline full-text search for docmd sites — builds a `search-index.json` at compile time, no API keys, no cloud, works in air-gapped environments. Powered by [minisearch](https://github.com/lucaong/minisearch) for fast fuzzy matching. Bundled with `@docmd/core`, enabled by default.
- **Zero Config:** Enabled by default.
- **Offline:** Generates a `search-index.json` at build time.
- **Fast:** Uses `minisearch` for fuzzy matching.
Part of the **[docmd](https://github.com/docmd-io/docmd)** documentation engine.
## The `docmd` Ecosystem
## Documentation
`docmd` is a modular system. Here are the official packages:
See **[docs.docmd.io](https://docs.docmd.io)** for full usage and API reference.
**The Engine**
* [**@docmd/core**](https://www.npmjs.com/package/@docmd/core) - The CLI runner and build orchestrator.
* [**@docmd/parser**](https://www.npmjs.com/package/@docmd/parser) - The pure Markdown-to-HTML logic.
* [**@docmd/live**](https://www.npmjs.com/package/@docmd/live) - The browser-based Live Editor bundle.
**Interface & Design**
* [**@docmd/ui**](https://www.npmjs.com/package/@docmd/ui) - Base EJS templates and assets.
* [**@docmd/themes**](https://www.npmjs.com/package/@docmd/themes) - Official themes (Sky, Ruby, Retro).
**Required Plugins**
* [**@docmd/plugin-installer**](https://www.npmjs.com/package/@docmd/plugin-installer) - Plugin installer for docmd.
* [**@docmd/plugin-search**](https://www.npmjs.com/package/@docmd/plugin-search) - Offline full-text search.
* [**@docmd/plugin-pwa**](https://www.npmjs.com/package/@docmd/plugin-pwa) - Progressive Web App support.
* [**@docmd/plugin-mermaid**](https://www.npmjs.com/package/@docmd/plugin-mermaid) - Diagrams and flowcharts.
* [**@docmd/plugin-seo**](https://www.npmjs.com/package/@docmd/plugin-seo) - Meta tags and Open Graph data.
* [**@docmd/plugin-sitemap**](https://www.npmjs.com/package/@docmd/plugin-sitemap) - Automatic sitemap generation.
* [**@docmd/plugin-llms**](https://www.npmjs.com/package/@docmd/plugin-llms) - AI context generation.
* [**@docmd/plugin-analytics**](https://www.npmjs.com/package/@docmd/plugin-analytics) - Google Analytics integration.
**Optional Plugins**
* [**@docmd/plugin-threads**](https://www.npmjs.com/package/@docmd/plugin-threads) - Inline discussion threads.
* [**@docmd/plugin-math**](https://www.npmjs.com/package/@docmd/plugin-math) - Mathematics (KaTeX/LaTeX) support.
## License
Distributed under the MIT License. See `LICENSE` for more information.
MIT