@docmd/plugin-search
Advanced tools
+10
| { | ||
| "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}" | ||
| } |
+10
| { | ||
| "searchPlaceholder": "दस्तावेज़ खोजें...", | ||
| "searchNoResults": "कोई परिणाम नहीं मिला।", | ||
| "searchError": "खोज अनुक्रमणिका लोड करने में विफल।", | ||
| "searchInitial": "खोजने के लिए टाइप करें...", | ||
| "searchClose": "खोज बंद करें", | ||
| "searchNavigate": "नेविगेट करें", | ||
| "searchEscape": "बंद करें", | ||
| "searchVersionBadge": "v{version}" | ||
| } |
+10
| { | ||
| "searchPlaceholder": "搜索文档...", | ||
| "searchNoResults": "未找到结果。", | ||
| "searchError": "无法加载搜索索引。", | ||
| "searchInitial": "输入开始搜索...", | ||
| "searchClose": "关闭搜索", | ||
| "searchNavigate": "导航", | ||
| "searchEscape": "关闭", | ||
| "searchVersionBadge": "v{version}" | ||
| } |
@@ -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) |
+16
-1
| /** | ||
| * -------------------------------------------------------------------- | ||
| * 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; |
+143
-31
| /** | ||
| * -------------------------------------------------------------------- | ||
| * 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> |
+3
-2
| { | ||
| "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": { |
+5
-30
| # @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 |
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
20130
42.1%9
50%327
92.35%13
-65.79%4
100%