@veluai/velu 0.2.0 → 0.2.1
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.
- package/dist/cli.js +1 -1
- package/package.json +2 -1
- package/runtime/velu-ui/components/ContextMenu.jsx +1 -0
- package/runtime/velu-ui/components/Search.jsx +66 -17
- package/runtime/velu-ui/components/Sidebar.jsx +55 -0
- package/runtime/velu-ui/components/context-menu.css +4 -1
- package/runtime/velu-ui/components/docs-layout.css +17 -4
- package/runtime/velu-ui/components/page-header.css +1 -0
- package/runtime/velu-ui/components/sidebar.css +64 -3
- package/runtime/velu-ui/components/theme-toggle.css +1 -0
- package/runtime/velu-ui/index.js +1 -0
- package/runtime/velu-ui/lib/brand-icons.jsx +9 -8
- package/runtime/velu-ui/lib/pagefind.js +113 -0
- package/src/runtime/App.jsx +31 -3
package/dist/cli.js
CHANGED
|
@@ -25,7 +25,7 @@ ${L.join(`,
|
|
|
25
25
|
`),$.join(`
|
|
26
26
|
`),"","export const pages = {",c.join(`
|
|
27
27
|
`),"};",`export const navigation = ${JSON.stringify(i)};`,`export const site = ${JSON.stringify(p)};`,""].join(`
|
|
28
|
-
`)}var Ot,At,Lt=j(()=>{B();oe();Y();Be();Ot="virtual:velu-site",At="\0"+Ot});import{visit as lr}from"unist-util-visit";function cr(e,t){let n=Pt(e).replace(/[?#].*$/,""),r=Pt(t).replace(/\/+$/,"");return r&&n.toLowerCase().startsWith(r.toLowerCase())?n.slice(r.length).replace(/^\/+/,""):null}function _t(e,t){return()=>(n,r)=>{typeof e=="function"&&lr(n,o=>{if(o.type!=="mdxJsxFlowElement"&&o.type!=="mdxJsxTextElement"||typeof o.name!="string"||!/^[A-Z]/.test(o.name)||o.name.includes("."))return;let i={};for(let s of o.attributes||[])s.type==="mdxJsxAttribute"&&(s.value==null?i[s.name]=!0:typeof s.value=="string"&&(i[s.name]=s.value));let a=e(o.name,i,{checkRequired:!1});if(a){let s=new Error(a.title);throw s.veluIssue={...a,file:cr(r?.path,t),line:o.position?.start?.line??null,column:o.position?.start?.column??null},s.loc={line:s.veluIssue.line,column:s.veluIssue.column},s}})}}var Pt,Ct=j(()=>{Pt=e=>String(e||"").replace(/\\/g,"/")});import ve from"node:path";import ur from"node:fs";import{createRequire as pr}from"node:module";import fr from"@tailwindcss/vite";import mr from"@vitejs/plugin-react";import dr from"@mdx-js/rollup";import gr from"remark-frontmatter";import hr from"remark-mdx-frontmatter";import yr from"remark-gfm";import vr from"rehype-slug";function br(e){let n=pr(import.meta.url).resolve("@mdx-js/react"),r=ve.resolve(e,"runtime","velu-ui"),o=ur.existsSync(r)?r:ve.resolve(e,"..","velu-ui","src");return[{find:"@mdx-js/react",replacement:n},{find:/^velu-ui$/,replacement:ve.join(o,"index.js")},{find:/^velu-ui\//,replacement:o+"/"}]}function Et({targetDir:e,VITE_ROOT:t,validateProps:n}){return{root:t,plugins:[Tt(e),dr({remarkPlugins:[gr,hr,yr,ne,_t(n,e)],rehypePlugins:[vr],providerImportSource:"@mdx-js/react"}),mr(),fr({optimize:!1})],cacheDir:ve.resolve(e,".velu","vite-cache"),optimizeDeps:{include:["react","react-dom","react-dom/client","react-router-dom","@mdx-js/react","lucide-react","prism-react-renderer","iso-639-1","github-slugger"],exclude:["tailwindcss","@tailwindcss/oxide","playwright","playwright-core","@apidevtools/swagger-parser","satori","@resvg/resvg-js"],esbuildOptions:{loader:{".mdx":"jsx"}}},resolve:{alias:br(t),dedupe:["react","react-dom","@mdx-js/react"]}}}var Ft=j(()=>{Re();Lt();Ct()});function A(e){return{category:"render-crash",severity:"error",file:null,line:null,column:null,frame:null,title:"Something went wrong",detail:"",hint:"",suggestion:null,...e}}function xr(e,t){if(e=String(e),t=String(t),e===t)return 0;if(!e.length)return t.length;if(!t.length)return e.length;let n=Array.from({length:t.length+1},(o,i)=>i),r=new Array(t.length+1);for(let o=1;o<=e.length;o++){r[0]=o;for(let i=1;i<=t.length;i++){let a=e[o-1]===t[i-1]?0:1;r[i]=Math.min(r[i-1]+1,n[i]+1,n[i-1]+a)}[n,r]=[r,n]}return n[t.length]}function be(e,t=[]){if(!e)return null;let n=String(e).toLowerCase(),r=null,o=1/0;for(let a of t){let s=xr(n,String(a).toLowerCase());s<o&&(o=s,r=a)}let i=Math.min(3,Math.max(2,Math.floor(n.length/4)));return o<=i?r:null}function $r(e,t){return e==="warning"?E(t,_.yellow,"\u26A0"):e==="info"?E(t,_.cyan,"\u2139"):E(t,_.red,"\u2716")}function Je(e,{color:t=!1}={}){let n=Ge[e.category]||e.category,r=e.line?`${e.line}${e.column?`:${e.column}`:""}`:"",o=e.file?`${e.file}${r?`:${r}`:""}`:r,i=[];if(i.push(`${$r(e.severity,t)} ${E(t,_.bold,n)}`+(o?` ${E(t,_.dim,o)}`:"")),e.title&&i.push(` ${e.title}`),e.detail&&e.detail!==e.title&&i.push(` ${E(t,_.dim,e.detail)}`),e.frame)for(let a of String(e.frame).split(`
|
|
28
|
+
`)}var Ot,At,Lt=j(()=>{B();oe();Y();Be();Ot="virtual:velu-site",At="\0"+Ot});import{visit as lr}from"unist-util-visit";function cr(e,t){let n=Pt(e).replace(/[?#].*$/,""),r=Pt(t).replace(/\/+$/,"");return r&&n.toLowerCase().startsWith(r.toLowerCase())?n.slice(r.length).replace(/^\/+/,""):null}function _t(e,t){return()=>(n,r)=>{typeof e=="function"&&lr(n,o=>{if(o.type!=="mdxJsxFlowElement"&&o.type!=="mdxJsxTextElement"||typeof o.name!="string"||!/^[A-Z]/.test(o.name)||o.name.includes("."))return;let i={};for(let s of o.attributes||[])s.type==="mdxJsxAttribute"&&(s.value==null?i[s.name]=!0:typeof s.value=="string"&&(i[s.name]=s.value));let a=e(o.name,i,{checkRequired:!1});if(a){let s=new Error(a.title);throw s.veluIssue={...a,file:cr(r?.path,t),line:o.position?.start?.line??null,column:o.position?.start?.column??null},s.loc={line:s.veluIssue.line,column:s.veluIssue.column},s}})}}var Pt,Ct=j(()=>{Pt=e=>String(e||"").replace(/\\/g,"/")});import ve from"node:path";import ur from"node:fs";import{createRequire as pr}from"node:module";import fr from"@tailwindcss/vite";import mr from"@vitejs/plugin-react";import dr from"@mdx-js/rollup";import gr from"remark-frontmatter";import hr from"remark-mdx-frontmatter";import yr from"remark-gfm";import vr from"rehype-slug";function br(e){let n=pr(import.meta.url).resolve("@mdx-js/react"),r=ve.resolve(e,"runtime","velu-ui"),o=ur.existsSync(r)?r:ve.resolve(e,"..","velu-ui","src");return[{find:"@mdx-js/react",replacement:n},{find:/^velu-ui$/,replacement:ve.join(o,"index.js")},{find:/^velu-ui\//,replacement:o+"/"}]}function Et({targetDir:e,VITE_ROOT:t,validateProps:n}){return{root:t,plugins:[Tt(e),dr({remarkPlugins:[gr,hr,yr,ne,_t(n,e)],rehypePlugins:[vr],providerImportSource:"@mdx-js/react"}),mr(),fr({optimize:!1})],cacheDir:ve.resolve(e,".velu","vite-cache"),optimizeDeps:{include:["react","react-dom","react-dom/client","react-router-dom","@mdx-js/react","lucide-react","prism-react-renderer","iso-639-1","github-slugger"],exclude:["tailwindcss","@tailwindcss/oxide","playwright","playwright-core","@apidevtools/swagger-parser","satori","@resvg/resvg-js","pagefind"],esbuildOptions:{loader:{".mdx":"jsx"}}},resolve:{alias:br(t),dedupe:["react","react-dom","@mdx-js/react"]}}}var Ft=j(()=>{Re();Lt();Ct()});function A(e){return{category:"render-crash",severity:"error",file:null,line:null,column:null,frame:null,title:"Something went wrong",detail:"",hint:"",suggestion:null,...e}}function xr(e,t){if(e=String(e),t=String(t),e===t)return 0;if(!e.length)return t.length;if(!t.length)return e.length;let n=Array.from({length:t.length+1},(o,i)=>i),r=new Array(t.length+1);for(let o=1;o<=e.length;o++){r[0]=o;for(let i=1;i<=t.length;i++){let a=e[o-1]===t[i-1]?0:1;r[i]=Math.min(r[i-1]+1,n[i]+1,n[i-1]+a)}[n,r]=[r,n]}return n[t.length]}function be(e,t=[]){if(!e)return null;let n=String(e).toLowerCase(),r=null,o=1/0;for(let a of t){let s=xr(n,String(a).toLowerCase());s<o&&(o=s,r=a)}let i=Math.min(3,Math.max(2,Math.floor(n.length/4)));return o<=i?r:null}function $r(e,t){return e==="warning"?E(t,_.yellow,"\u26A0"):e==="info"?E(t,_.cyan,"\u2139"):E(t,_.red,"\u2716")}function Je(e,{color:t=!1}={}){let n=Ge[e.category]||e.category,r=e.line?`${e.line}${e.column?`:${e.column}`:""}`:"",o=e.file?`${e.file}${r?`:${r}`:""}`:r,i=[];if(i.push(`${$r(e.severity,t)} ${E(t,_.bold,n)}`+(o?` ${E(t,_.dim,o)}`:"")),e.title&&i.push(` ${e.title}`),e.detail&&e.detail!==e.title&&i.push(` ${E(t,_.dim,e.detail)}`),e.frame)for(let a of String(e.frame).split(`
|
|
29
29
|
`))i.push(` ${a}`);return e.suggestion&&i.push(` ${E(t,_.cyan,`Did you mean <${e.suggestion}>?`)}`),e.hint&&i.push(` ${E(t,_.dim,`\u2192 ${e.hint}`)}`),i.join(`
|
|
30
30
|
`)}function xe(e,{color:t=!1}={}){if(!e.length)return E(t,_.cyan,"\u2713 No issues found.");let n=new Map;for(let s of e){let l=s.file||"velu.json";n.has(l)||n.set(l,[]),n.get(l).push(s)}let r=[];for(let[s,l]of n){r.push(E(t,_.bold,s));for(let p of l)r.push(Je({...p,file:null},{color:t}).replace(/^/gm," "));r.push("")}let o=e.filter(s=>s.severity!=="warning"&&s.severity!=="info").length,i=e.length-o,a=`${o} error${o===1?"":"s"}`+(i?`, ${i} warning${i===1?"":"s"}`:"");return r.push(E(t,o?_.red:_.yellow,a)),r.join(`
|
|
31
31
|
`)}var Ge,_,E,J=j(()=>{Ge={"mdx-syntax":"MDX syntax",frontmatter:"Frontmatter","unknown-component":"Unknown component","invalid-props":"Invalid options","render-crash":"Render error","config-json":"velu.json","config-schema":"velu.json","config-nav":"Navigation","config-asset":"Asset"};_={reset:"\x1B[0m",red:"\x1B[31m",yellow:"\x1B[33m",cyan:"\x1B[36m",dim:"\x1B[2m",bold:"\x1B[1m"},E=(e,t,n)=>e?`${t}${n}${_.reset}`:n});function wr(e){let t=e,n=0;for(;t&&t.cause&&n<3&&!(t.loc||t.line||/`[^`]+` to be defined/.test(t.message||""));)t=t.cause,n+=1;return t||e}function Ve(e,t){if(!e)return null;let n=Nt(e).replace(/[?#].*$/,""),r=t?Nt(t).replace(/\/+$/,""):"";return r&&n.toLowerCase().startsWith(r.toLowerCase())?n.slice(r.length).replace(/^\/+/,""):!/^([a-zA-Z]:\/|\/)/.test(n)&&!/node_modules/.test(n)?n:null}function Sr(e){return e?String(e).replace(/([a-zA-Z]:\\|\/)[^\s'"()]+/g,t=>/node_modules|[\\/]vite[\\/]|@veluai|velu-cli|velu-ui/.test(t)?"\u2026":t).trim():""}function kr(e){let t=/\((\d+):(\d+)(?:-\d+:\d+)?\)\s*$/.exec(String(e||""));return t?{line:Number(t[1]),column:Number(t[2])}:null}function jr(e){return String(e||"").replace(/\s*\(\d+:\d+(?:-\d+:\d+)?\)\s*$/,"").trim()}function Or(e,t){if(!e||!t)return null;let n=String(e);return/node_modules|[\\/]vite[\\/]|@veluai[\\/]/.test(n)?null:n}function ae(e,t={}){let{projectDir:n="",knownComponents:r=[],file:o=null}=t,i=null;for(let h=e,w=0;h&&w<6;h=h.cause,w+=1)if(h.veluIssue){i=h.veluIssue;break}if(i)return A({...i,file:i.file||o});let a=wr(e)||{},s=a.message||String(e||""),l=/Expected component `([^`]+)` to be defined/.exec(s);if(l){let h=l[1],w=/referenced in your code at `(\d+):(\d+)/.exec(s),F=/\bin `([^`]+)`/.exec(s),x=F?Ve(F[1],n):null;return A({category:"unknown-component",file:x||o,line:w?Number(w[1]):null,column:w?Number(w[2]):null,title:`<${h}> is not a built-in component`,detail:"",hint:"Components are built in \u2014 check the spelling and capitalization (you don\u2019t import them).",suggestion:be(h,r)})}let p=a.loc||(a.line!=null?{line:a.line,column:a.column,file:a.file}:null)||kr(a.reason||s),u=a.id||p&&p.file||a.file||null,c=Ve(u,n)||o,b=!!c&&Ve(u,n)!=null,d=Or(a.frame,b||!!o),$=Sr(jr(a.reason||s)),m=Ar.test(s),L=a.source==="remark-frontmatter"||/\byaml\b|frontmatter/i.test(s)||p&&p.line===1&&/unexpected|expected/i.test(s)&&/---/.test(s);return p||m||d&&b?A({category:L?"frontmatter":"mdx-syntax",file:c,line:p?p.line:null,column:p?p.column:null,frame:d,title:$||(L?"Invalid frontmatter":"MDX syntax error"),detail:"",hint:L?"Check the YAML between the --- markers \u2014 indentation, quotes, and colons.":"Make sure every <Component> has a matching closing tag and that { } braces are balanced."}):/\.map is not a function|is not iterable|reading '|undefined \(reading/i.test(s)?A({category:"invalid-props",file:c,title:"A component received an invalid value",detail:$,hint:"Check the props/options you passed to a component on this page."}):A({category:"render-crash",file:c,title:"This page failed to render",detail:$,hint:"Set VELU_DEBUG=1 for the full stack trace."})}var Nt,Ar,qe=j(()=>{J();Nt=e=>String(e).replace(/\\/g,"/");Ar=/closing tag|unexpected closing|could not parse (?:expression|import|export)|unexpected character|unexpected end of|expected a closing|expected the closing|unexpected `|in expression|misnested|before name/i});function Tr(e){let t=Ge[e.category]||e.category,n=e.file?`${e.file}${e.line?`:${e.line}${e.column?`:${e.column}`:""}`:""}`:"",r=[];return r.push('<div class="velu-err-card">'),r.push('<div class="velu-err-head">'),r.push(`<span class="velu-err-pill">${I(t)}</span>`),r.push("</div>"),e.title&&r.push(`<div class="velu-err-title">${I(e.title)}</div>`),n&&r.push(`<a class="velu-err-loc" title="${I(n)}">${I(n)}</a>`),e.detail&&r.push(`<div class="velu-err-detail">${I(e.detail)}</div>`),e.frame&&r.push(`<pre class="velu-err-frame">${I(e.frame)}</pre>`),e.suggestion&&r.push(`<div class="velu-err-suggest">Did you mean <code><${I(e.suggestion)}></code>?</div>`),e.hint&&r.push(`<div class="velu-err-hint">${I(e.hint)}</div>`),r.push("</div>"),r.join("")}function He(e,t={}){let n=Array.isArray(e)?e:[e],r=t.title||(n.length>1?`${n.length} problems to fix`:"There\u2019s a problem to fix"),o=n.map(Tr).join(`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veluai/velu",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": "./dist/cli.js",
|
|
6
6
|
"publishConfig": {
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"@fontsource/outfit": "^5.0.0",
|
|
57
57
|
"@resvg/resvg-js": "^2.6.2",
|
|
58
58
|
"esbuild": "^0.28.0",
|
|
59
|
+
"pagefind": "^1.3.0",
|
|
59
60
|
"playwright": "^1.48.0",
|
|
60
61
|
"satori": "^0.12.0"
|
|
61
62
|
}
|
|
@@ -162,10 +162,14 @@ function PaletteRow({ item, selected, navMode, onHover, onSelect }) {
|
|
|
162
162
|
);
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
/* The revealed palette — scrim + centered panel.
|
|
166
|
-
|
|
165
|
+
/* The revealed palette — scrim + centered panel. When `search` (an async
|
|
166
|
+
query → results function, e.g. the Pagefind client) is provided, results
|
|
167
|
+
come from it; otherwise the static `results` list is filtered in-memory. */
|
|
168
|
+
function SearchPalette({ results, search, placeholder, onSelect, onClose }) {
|
|
167
169
|
const [query, setQuery] = useState('');
|
|
168
170
|
const [selected, setSelected] = useState(0);
|
|
171
|
+
const [asyncResults, setAsyncResults] = useState([]);
|
|
172
|
+
const [searching, setSearching] = useState(false);
|
|
169
173
|
const inputRef = useRef(null);
|
|
170
174
|
// 'keyboard' | 'mouse' — which input last moved the selection; gates
|
|
171
175
|
// the rows' scrollIntoView so mouse hover doesn't jitter the list.
|
|
@@ -175,7 +179,32 @@ function SearchPalette({ results, placeholder, onSelect, onClose }) {
|
|
|
175
179
|
inputRef.current?.focus();
|
|
176
180
|
}, []);
|
|
177
181
|
|
|
182
|
+
// Async source (Pagefind): debounce the query, fetch results.
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (!search) return undefined;
|
|
185
|
+
const q = query.trim();
|
|
186
|
+
if (!q) {
|
|
187
|
+
setAsyncResults([]);
|
|
188
|
+
setSearching(false);
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
let cancelled = false;
|
|
192
|
+
setSearching(true);
|
|
193
|
+
const t = setTimeout(async () => {
|
|
194
|
+
const r = await search(q).catch(() => []);
|
|
195
|
+
if (!cancelled) {
|
|
196
|
+
setAsyncResults(Array.isArray(r) ? r : []);
|
|
197
|
+
setSearching(false);
|
|
198
|
+
}
|
|
199
|
+
}, 150);
|
|
200
|
+
return () => {
|
|
201
|
+
cancelled = true;
|
|
202
|
+
clearTimeout(t);
|
|
203
|
+
};
|
|
204
|
+
}, [query, search]);
|
|
205
|
+
|
|
178
206
|
const filtered = useMemo(() => {
|
|
207
|
+
if (search) return asyncResults;
|
|
179
208
|
const q = query.trim().toLowerCase();
|
|
180
209
|
if (!q) {
|
|
181
210
|
// Empty query → just the recents.
|
|
@@ -185,15 +214,18 @@ function SearchPalette({ results, placeholder, onSelect, onClose }) {
|
|
|
185
214
|
const hay = `${r.title} ${r.breadcrumb.join(' ')} ${r.desc}`.toLowerCase();
|
|
186
215
|
return hay.includes(q);
|
|
187
216
|
});
|
|
188
|
-
}, [query, results]);
|
|
217
|
+
}, [search, asyncResults, query, results]);
|
|
189
218
|
|
|
190
219
|
const grouped = useMemo(() => {
|
|
191
220
|
const map = new Map();
|
|
192
221
|
filtered.forEach((r) => {
|
|
193
|
-
|
|
194
|
-
map.
|
|
222
|
+
const g = r.group || 'Pages';
|
|
223
|
+
if (!map.has(g)) map.set(g, []);
|
|
224
|
+
map.get(g).push(r);
|
|
195
225
|
});
|
|
196
|
-
|
|
226
|
+
const known = GROUP_ORDER.filter((g) => map.has(g));
|
|
227
|
+
const extra = [...map.keys()].filter((g) => !GROUP_ORDER.includes(g));
|
|
228
|
+
return [...known, ...extra].map((g) => [g, map.get(g)]);
|
|
197
229
|
}, [filtered]);
|
|
198
230
|
|
|
199
231
|
const flat = useMemo(
|
|
@@ -266,19 +298,34 @@ function SearchPalette({ results, placeholder, onSelect, onClose }) {
|
|
|
266
298
|
</div>
|
|
267
299
|
|
|
268
300
|
<div className="velu-search__list" role="listbox">
|
|
269
|
-
{grouped.length === 0 &&
|
|
270
|
-
|
|
271
|
-
<
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
301
|
+
{grouped.length === 0 &&
|
|
302
|
+
(search && !query.trim() ? (
|
|
303
|
+
<div className="velu-search__empty">
|
|
304
|
+
<span className="velu-search__empty-icon" aria-hidden="true">
|
|
305
|
+
{resolveIcon('search', { size: '1em' })}
|
|
306
|
+
</span>
|
|
307
|
+
<div className="velu-search__empty-title">Search the docs</div>
|
|
308
|
+
<div className="velu-search__empty-sub">
|
|
309
|
+
Type to find pages and sections.
|
|
310
|
+
</div>
|
|
276
311
|
</div>
|
|
277
|
-
|
|
278
|
-
|
|
312
|
+
) : searching ? (
|
|
313
|
+
<div className="velu-search__empty">
|
|
314
|
+
<div className="velu-search__empty-sub">Searching…</div>
|
|
279
315
|
</div>
|
|
280
|
-
|
|
281
|
-
|
|
316
|
+
) : (
|
|
317
|
+
<div className="velu-search__empty">
|
|
318
|
+
<span className="velu-search__empty-icon" aria-hidden="true">
|
|
319
|
+
{resolveIcon('search-x', { size: '1em' })}
|
|
320
|
+
</span>
|
|
321
|
+
<div className="velu-search__empty-title">
|
|
322
|
+
No results for “{query}”
|
|
323
|
+
</div>
|
|
324
|
+
<div className="velu-search__empty-sub">
|
|
325
|
+
Try a different keyword or browse the sidebar.
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
))}
|
|
282
329
|
{grouped.map(([group, items]) => (
|
|
283
330
|
<div key={group}>
|
|
284
331
|
<div className="velu-search__group">{group}</div>
|
|
@@ -346,6 +393,7 @@ function SearchUnavailable({ message, onClose }) {
|
|
|
346
393
|
|
|
347
394
|
export default function Search({
|
|
348
395
|
results = DEFAULT_RESULTS,
|
|
396
|
+
search,
|
|
349
397
|
placeholder = 'Search documentation',
|
|
350
398
|
onSelect,
|
|
351
399
|
unavailable = false,
|
|
@@ -401,6 +449,7 @@ export default function Search({
|
|
|
401
449
|
) : (
|
|
402
450
|
<SearchPalette
|
|
403
451
|
results={results}
|
|
452
|
+
search={search}
|
|
404
453
|
placeholder={placeholder}
|
|
405
454
|
onSelect={onSelect}
|
|
406
455
|
onClose={() => setOpen(false)}
|
|
@@ -41,6 +41,11 @@ import scrollIntoNearestView from '../lib/scrollIntoNearestView.js';
|
|
|
41
41
|
|
|
42
42
|
const SidebarCtx = React.createContext({ activeHref: undefined, Link: 'a' });
|
|
43
43
|
|
|
44
|
+
// Positioning must happen before paint (no flash); fall back to useEffect on
|
|
45
|
+
// the server so React doesn't warn about useLayoutEffect during SSR.
|
|
46
|
+
const useIsoLayoutEffect =
|
|
47
|
+
typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
|
|
48
|
+
|
|
44
49
|
function isActive(item, activeHref) {
|
|
45
50
|
return Boolean(
|
|
46
51
|
item.active || (item.href != null && item.href === activeHref)
|
|
@@ -166,6 +171,47 @@ export default function Sidebar({
|
|
|
166
171
|
scrollIntoNearestView(el);
|
|
167
172
|
}, [activeHref]);
|
|
168
173
|
|
|
174
|
+
// The single gliding active indicator (crimson tint + flush-left bar). One
|
|
175
|
+
// element animates its position/height to the active item, so selection feels
|
|
176
|
+
// continuous rather than each item popping its own bar. It lives in the scroll
|
|
177
|
+
// content, so its offset is scroll-stable (no re-measure on scroll) and a
|
|
178
|
+
// pinned sticky heading's opaque background covers it when an item tucks under.
|
|
179
|
+
const [ind, setInd] = React.useState({ top: 0, h: 0, show: false });
|
|
180
|
+
const measure = React.useCallback(() => {
|
|
181
|
+
const nav = rootRef.current;
|
|
182
|
+
if (!nav) return;
|
|
183
|
+
const el = nav.querySelector('[aria-current="page"]');
|
|
184
|
+
if (!el) {
|
|
185
|
+
setInd((s) => (s.show ? { ...s, show: false } : s));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const top = el.getBoundingClientRect().top - nav.getBoundingClientRect().top;
|
|
189
|
+
const h = el.offsetHeight;
|
|
190
|
+
// Bail when nothing moved → no needless re-render (and no animation jitter).
|
|
191
|
+
setInd((s) =>
|
|
192
|
+
s.show && Math.abs(s.top - top) < 0.5 && Math.abs(s.h - h) < 0.5
|
|
193
|
+
? s
|
|
194
|
+
: { top, h, show: true },
|
|
195
|
+
);
|
|
196
|
+
}, []);
|
|
197
|
+
// Re-measure on selection / tab change…
|
|
198
|
+
useIsoLayoutEffect(() => {
|
|
199
|
+
measure();
|
|
200
|
+
}, [activeHref, sections, measure]);
|
|
201
|
+
// …and whenever the nav's layout changes (a group expands/collapses → the
|
|
202
|
+
// active item moves), plus on viewport resize.
|
|
203
|
+
React.useEffect(() => {
|
|
204
|
+
const nav = rootRef.current;
|
|
205
|
+
if (!nav || typeof ResizeObserver === 'undefined') return undefined;
|
|
206
|
+
const ro = new ResizeObserver(() => measure());
|
|
207
|
+
ro.observe(nav);
|
|
208
|
+
window.addEventListener('resize', measure);
|
|
209
|
+
return () => {
|
|
210
|
+
ro.disconnect();
|
|
211
|
+
window.removeEventListener('resize', measure);
|
|
212
|
+
};
|
|
213
|
+
}, [measure]);
|
|
214
|
+
|
|
169
215
|
return (
|
|
170
216
|
<SidebarCtx.Provider value={ctx}>
|
|
171
217
|
<Stack
|
|
@@ -176,6 +222,15 @@ export default function Sidebar({
|
|
|
176
222
|
aria-label="Documentation"
|
|
177
223
|
{...rest}
|
|
178
224
|
>
|
|
225
|
+
<span
|
|
226
|
+
className="velu-sidebar__indicator"
|
|
227
|
+
aria-hidden="true"
|
|
228
|
+
style={{
|
|
229
|
+
transform: `translateY(${ind.top}px)`,
|
|
230
|
+
height: `${ind.h}px`,
|
|
231
|
+
opacity: ind.show ? 1 : 0,
|
|
232
|
+
}}
|
|
233
|
+
/>
|
|
179
234
|
{/* Each section is its own Stack so the heading sits TIGHT to
|
|
180
235
|
its list (small inner gap), while the nav's larger gap
|
|
181
236
|
separates one section from the next — compact but still
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
tokens, light/dark via [data-theme]. */
|
|
4
4
|
|
|
5
5
|
.velu-context-bar {
|
|
6
|
-
|
|
6
|
+
/* Tight to the page title below — the eyebrow reads as a kicker on the
|
|
7
|
+
title, not a detached row. Negative pull cancels the h1's line-height
|
|
8
|
+
leading so the cap-height of the title sits right under the eyebrow. */
|
|
9
|
+
margin-block-end: calc(-1 * var(--s-5));
|
|
7
10
|
}
|
|
8
11
|
|
|
9
12
|
/* Section/group label, top-left — small, uppercase, accent (matches the
|
|
@@ -102,6 +102,16 @@
|
|
|
102
102
|
/* Region wrapping the scroll area + the overlay arrows. Fills the
|
|
103
103
|
remaining aside height; relative so the arrows position to its
|
|
104
104
|
top/bottom edges. */
|
|
105
|
+
/* Hairline between the top anchor links and the nav sections (the sidebar
|
|
106
|
+
design's anchor↔sidebar separator). Tokenized; sits flush in the aside flow. */
|
|
107
|
+
.velu-docs-context-divider {
|
|
108
|
+
flex: none;
|
|
109
|
+
block-size: var(--border-width);
|
|
110
|
+
background: var(--border-color);
|
|
111
|
+
margin-block: var(--s-2);
|
|
112
|
+
margin-inline: var(--s-2);
|
|
113
|
+
}
|
|
114
|
+
|
|
105
115
|
.velu-docs-nav-region {
|
|
106
116
|
position: relative;
|
|
107
117
|
flex: 1 1 auto;
|
|
@@ -200,10 +210,13 @@
|
|
|
200
210
|
.velu-docs-layout__aside--right[data-fade-top='true'] {
|
|
201
211
|
--vf-top: var(--s2);
|
|
202
212
|
}
|
|
203
|
-
/* The left nav's under-heading fade (the ::after below
|
|
204
|
-
|
|
205
|
-
section
|
|
206
|
-
|
|
213
|
+
/* The left nav's under-heading fade (the ::after below a sticky section
|
|
214
|
+
heading) is shown only while the nav is scrolled up AND only under the
|
|
215
|
+
currently-PINNED section ([data-stuck], set by the runtime). Other sections'
|
|
216
|
+
headings scroll normally, so their first item must not be dimmed — only the
|
|
217
|
+
item sliding under the top-pinned heading fades. */
|
|
218
|
+
.velu-docs-nav-scroll[data-fade-top='true']
|
|
219
|
+
.velu-sidebar__section[data-stuck='true']::after {
|
|
207
220
|
opacity: 1;
|
|
208
221
|
}
|
|
209
222
|
.velu-docs-nav-scroll[data-fade-bottom='true'],
|
|
@@ -7,10 +7,51 @@
|
|
|
7
7
|
rotation, and centralized icon sizing/stroke. All values are tokens. */
|
|
8
8
|
|
|
9
9
|
.velu-sidebar {
|
|
10
|
-
|
|
10
|
+
position: relative; /* anchors the gliding active indicator */
|
|
11
11
|
margin-left: var(--s1);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/* The single active indicator — a crimson tint band with a flush-left 2px bar
|
|
15
|
+
that GLIDES between items (one element animating its position/height) instead
|
|
16
|
+
of each item painting its own active state. z-index 0 keeps it BEHIND the
|
|
17
|
+
items + sticky headings (both positioned, z-index 1), so the text reads over
|
|
18
|
+
the tint and a pinned heading's opaque background covers the indicator when an
|
|
19
|
+
item scrolls under it. Driven imperatively from JS (transform/height/opacity);
|
|
20
|
+
the easing curve makes the slide feel continuous. */
|
|
21
|
+
.velu-sidebar__indicator {
|
|
22
|
+
position: absolute;
|
|
23
|
+
inset-inline-start: 0;
|
|
24
|
+
inset-inline-end: var(--s-3);
|
|
25
|
+
inset-block-start: 0;
|
|
26
|
+
height: 0;
|
|
27
|
+
opacity: 0;
|
|
28
|
+
z-index: 0;
|
|
29
|
+
pointer-events: none;
|
|
30
|
+
background: color-mix(in srgb, var(--accent-color) 8%, transparent);
|
|
31
|
+
border-radius: var(--radius-sm);
|
|
32
|
+
transition:
|
|
33
|
+
transform 0.3s cubic-bezier(0.32, 0.72, 0, 1),
|
|
34
|
+
height 0.3s cubic-bezier(0.32, 0.72, 0, 1),
|
|
35
|
+
opacity 0.2s ease;
|
|
36
|
+
will-change: transform, height;
|
|
37
|
+
}
|
|
38
|
+
.velu-sidebar__indicator::before {
|
|
39
|
+
content: '';
|
|
40
|
+
position: absolute;
|
|
41
|
+
inset-inline-start: 0;
|
|
42
|
+
inset-block: 15%;
|
|
43
|
+
inline-size: 2px;
|
|
44
|
+
border-start-end-radius: 2px;
|
|
45
|
+
border-end-end-radius: 2px;
|
|
46
|
+
background: var(--accent-color);
|
|
47
|
+
}
|
|
48
|
+
/* Honour reduced-motion: snap instead of glide. */
|
|
49
|
+
@media (prefers-reduced-motion: reduce) {
|
|
50
|
+
.velu-sidebar__indicator {
|
|
51
|
+
transition: opacity 0.2s ease;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
14
55
|
.velu-sidebar__section {
|
|
15
56
|
/* Sticky section header: each heading pins to the top of the scroll
|
|
16
57
|
region until its own section scrolls past, then the next heading
|
|
@@ -21,7 +62,10 @@
|
|
|
21
62
|
bar is invisible until it's actually covering scrolled content. */
|
|
22
63
|
position: sticky;
|
|
23
64
|
inset-block-start: 0;
|
|
24
|
-
z-index
|
|
65
|
+
/* Above the items (z-index 1) so scrolled rows tuck UNDER the heading (its
|
|
66
|
+
opaque bg + the ::after fade hide them), not over it; above the gliding
|
|
67
|
+
indicator (z-index 0) too. */
|
|
68
|
+
z-index: 2;
|
|
25
69
|
background: var(--page-bg);
|
|
26
70
|
display: flex;
|
|
27
71
|
align-items: center;
|
|
@@ -94,21 +138,38 @@
|
|
|
94
138
|
so these stay CSS until/if a Cluster/Inline primitive exists. */
|
|
95
139
|
.velu-sidebar__item,
|
|
96
140
|
.velu-sidebar__subitem {
|
|
141
|
+
/* Positioned above the gliding indicator (z-index 0) so the label reads over
|
|
142
|
+
the tint band. */
|
|
143
|
+
position: relative;
|
|
144
|
+
z-index: 1;
|
|
97
145
|
display: flex;
|
|
98
146
|
align-items: center;
|
|
99
147
|
gap: var(--s-3);
|
|
100
148
|
padding-inline: var(--s0);
|
|
101
149
|
color: var(--text-color);
|
|
102
150
|
text-decoration: none;
|
|
151
|
+
transition: color 0.2s ease;
|
|
103
152
|
}
|
|
104
153
|
|
|
105
154
|
.velu-sidebar__subitem {
|
|
106
155
|
padding-block: var(--s-5);
|
|
107
156
|
}
|
|
108
157
|
|
|
158
|
+
/* Active = colour + weight only; the moving indicator paints the bar + tint. */
|
|
109
159
|
.velu-sidebar__item--active {
|
|
110
160
|
color: var(--accent-color);
|
|
111
|
-
|
|
161
|
+
font-weight: 500;
|
|
162
|
+
}
|
|
163
|
+
.velu-sidebar__item--active .velu-sidebar__icon {
|
|
164
|
+
color: var(--accent-color);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* The label fills the row so the group chevron (and any external-link icon)
|
|
168
|
+
sit flush at the row's RIGHT end, not crowded against the label — faithful
|
|
169
|
+
to the design's `flex: 1` label. */
|
|
170
|
+
.velu-sidebar__label {
|
|
171
|
+
flex: 1;
|
|
172
|
+
min-inline-size: 0;
|
|
112
173
|
}
|
|
113
174
|
|
|
114
175
|
.velu-sidebar__summary {
|
package/runtime/velu-ui/index.js
CHANGED
|
@@ -42,6 +42,7 @@ export {
|
|
|
42
42
|
VeluMark,
|
|
43
43
|
} from './components/PageHeader.jsx';
|
|
44
44
|
export { default as Search } from './components/Search.jsx';
|
|
45
|
+
export { default as pagefindSearch } from './lib/pagefind.js';
|
|
45
46
|
export { default as Image } from './components/Image.jsx';
|
|
46
47
|
export {
|
|
47
48
|
default as CodeBlock,
|
|
@@ -6,9 +6,10 @@ import React from 'react';
|
|
|
6
6
|
* like lucide — no SVG loader). Each takes a `size` prop, mirroring lucide's
|
|
7
7
|
* icon API so they're drop-in in the menu.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* ALL marks use `currentColor` so the icon column reads as one uniform set:
|
|
10
|
+
* muted/grey at rest, accent on hover (matching the lucide icons beside them).
|
|
11
|
+
* The brand glyphs keep their distinctive shapes; only the fill follows the
|
|
12
|
+
* menu's text/hover color across light + dark.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
export function MarkdownIcon({ size = '1em', ...rest }) {
|
|
@@ -39,7 +40,7 @@ export function OpenAIIcon({ size = '1em', ...rest }) {
|
|
|
39
40
|
export function ClaudeIcon({ size = '1em', ...rest }) {
|
|
40
41
|
return (
|
|
41
42
|
<svg width={size} height={size} viewBox="0 0 256 257" aria-hidden="true" {...rest}>
|
|
42
|
-
<path fill="
|
|
43
|
+
<path fill="currentColor" d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z" />
|
|
43
44
|
</svg>
|
|
44
45
|
);
|
|
45
46
|
}
|
|
@@ -47,7 +48,7 @@ export function ClaudeIcon({ size = '1em', ...rest }) {
|
|
|
47
48
|
export function PerplexityIcon({ size = '1em', ...rest }) {
|
|
48
49
|
return (
|
|
49
50
|
<svg width={size} height={size} viewBox="0 0 48 48" fill="none" aria-hidden="true" {...rest}>
|
|
50
|
-
<g stroke="
|
|
51
|
+
<g stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
|
51
52
|
<path d="M24 4.5v39M13.73 16.573v-9.99L24 16.573m0 14.5L13.73 41.417V27.01L24 16.573m0 0l10.27-9.99v9.99" />
|
|
52
53
|
<path d="M13.73 31.396H9.44V16.573h29.12v14.823h-4.29" />
|
|
53
54
|
<path d="M24 16.573L34.27 27.01v14.407L24 31.073" />
|
|
@@ -85,15 +86,15 @@ export function VscodeIcon({ size = '1em', ...rest }) {
|
|
|
85
86
|
</mask>
|
|
86
87
|
<g mask="url(#velu-vsc-mask)">
|
|
87
88
|
<path
|
|
88
|
-
fill="
|
|
89
|
+
fill="currentColor"
|
|
89
90
|
d="M96.461 10.796 75.857.876a6.23 6.23 0 0 0-7.107 1.207l-67.451 61.5a4.167 4.167 0 0 0 .004 6.162l5.51 5.009a4.167 4.167 0 0 0 5.32.236l81.228-61.62c2.725-2.067 6.639-.124 6.639 3.297v-.24a6.25 6.25 0 0 0-3.539-5.63Z"
|
|
90
91
|
/>
|
|
91
92
|
<path
|
|
92
|
-
fill="
|
|
93
|
+
fill="currentColor"
|
|
93
94
|
d="m96.461 89.204-20.604 9.92a6.229 6.229 0 0 1-7.107-1.207l-67.451-61.5a4.167 4.167 0 0 1 .004-6.162l5.51-5.009a4.167 4.167 0 0 1 5.32-.236l81.228 61.62c2.725 2.067 6.639.124 6.639-3.297v.24a6.25 6.25 0 0 1-3.539 5.63Z"
|
|
94
95
|
/>
|
|
95
96
|
<path
|
|
96
|
-
fill="
|
|
97
|
+
fill="currentColor"
|
|
97
98
|
d="M75.858 99.126a6.232 6.232 0 0 1-7.108-1.21c2.306 2.307 6.25.674 6.25-2.588V4.672c0-3.262-3.944-4.895-6.25-2.589a6.232 6.232 0 0 1 7.108-1.21l20.6 9.908A6.25 6.25 0 0 1 100 16.413v67.174a6.25 6.25 0 0 1-3.541 5.633l-20.601 9.906Z"
|
|
98
99
|
/>
|
|
99
100
|
</g>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Pagefind client — queries the static search index that `velu build` writes to
|
|
2
|
+
// /pagefind/. The index is a chunked, lazy-loaded WASM bundle: the browser only
|
|
3
|
+
// downloads the fragments it needs per query, so search scales to huge sites
|
|
4
|
+
// without shipping one giant index. Generated at build time; absent in dev
|
|
5
|
+
// (the dev preview shows search as "unavailable").
|
|
6
|
+
//
|
|
7
|
+
// The runtime is loaded via a NON-analyzable dynamic import (a Function-wrapped
|
|
8
|
+
// import) so neither Vite nor the bundler tries to resolve /pagefind/pagefind.js
|
|
9
|
+
// at build time — it only exists in the deployed output.
|
|
10
|
+
|
|
11
|
+
let _pf = null;
|
|
12
|
+
let _loading = null;
|
|
13
|
+
|
|
14
|
+
async function loadRuntime() {
|
|
15
|
+
if (_pf) return _pf;
|
|
16
|
+
if (_loading) return _loading;
|
|
17
|
+
_loading = (async () => {
|
|
18
|
+
const importer = new Function('p', 'return import(p)');
|
|
19
|
+
const mod = await importer('/pagefind/pagefind.js');
|
|
20
|
+
if (typeof mod.init === 'function') await mod.init();
|
|
21
|
+
_pf = mod;
|
|
22
|
+
return mod;
|
|
23
|
+
})();
|
|
24
|
+
try {
|
|
25
|
+
return await _loading;
|
|
26
|
+
} catch (err) {
|
|
27
|
+
_loading = null;
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const stripHtml = (s) => String(s || '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
33
|
+
|
|
34
|
+
// Pagefind returns directory URLs (/quickstart/, /a/b/, /); normalize to the
|
|
35
|
+
// router's path form (/quickstart, /a/b, /). Preserve any #anchor.
|
|
36
|
+
function normalizeUrl(url) {
|
|
37
|
+
if (!url) return '/';
|
|
38
|
+
let [path, hash] = String(url).split('#');
|
|
39
|
+
path = path.replace(/index\.html?$/i, '').replace(/\.html?$/i, '');
|
|
40
|
+
if (path.length > 1) path = path.replace(/\/+$/, '');
|
|
41
|
+
if (!path) path = '/';
|
|
42
|
+
return hash ? `${path}#${hash}` : path;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Breadcrumb from the URL path (everything but the leaf), title-cased.
|
|
46
|
+
function crumbsFromUrl(url) {
|
|
47
|
+
const segs = String(url).split('#')[0].split('/').filter(Boolean);
|
|
48
|
+
segs.pop();
|
|
49
|
+
return segs.map((s) => s.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run a query against the static Pagefind index.
|
|
54
|
+
* @returns {Promise<Array<SearchResult>|null>} palette-shaped results, or null
|
|
55
|
+
* when the index can't be loaded (→ caller can show "unavailable").
|
|
56
|
+
*/
|
|
57
|
+
export default async function pagefindSearch(query) {
|
|
58
|
+
const q = String(query || '').trim();
|
|
59
|
+
if (!q) return [];
|
|
60
|
+
|
|
61
|
+
let pf;
|
|
62
|
+
try {
|
|
63
|
+
pf = await loadRuntime();
|
|
64
|
+
} catch {
|
|
65
|
+
return null; // index not present (e.g. not built) → unavailable
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let response;
|
|
69
|
+
try {
|
|
70
|
+
response = await pf.search(q);
|
|
71
|
+
} catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const hits = await Promise.all(response.results.slice(0, 8).map((r) => r.data()));
|
|
76
|
+
const out = [];
|
|
77
|
+
const seen = new Set(); // dedupe by href
|
|
78
|
+
for (const hit of hits) {
|
|
79
|
+
const href = normalizeUrl(hit.url);
|
|
80
|
+
const title = hit.meta?.title || crumbsFromUrl(hit.url).pop() || href;
|
|
81
|
+
if (!seen.has(href)) {
|
|
82
|
+
seen.add(href);
|
|
83
|
+
out.push({
|
|
84
|
+
id: href,
|
|
85
|
+
group: 'Pages',
|
|
86
|
+
breadcrumb: crumbsFromUrl(hit.url),
|
|
87
|
+
title,
|
|
88
|
+
kind: 'page',
|
|
89
|
+
desc: stripHtml(hit.excerpt),
|
|
90
|
+
href,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// Heading-level matches → deep-linkable anchor rows. Skip the page's own
|
|
94
|
+
// top section (pagefind repeats it as a sub-result with the page title).
|
|
95
|
+
for (const sub of hit.sub_results || []) {
|
|
96
|
+
const subHref = normalizeUrl(sub.url);
|
|
97
|
+
if (seen.has(subHref) || !subHref.includes('#') || sub.title === title) continue;
|
|
98
|
+
seen.add(subHref);
|
|
99
|
+
out.push({
|
|
100
|
+
id: subHref,
|
|
101
|
+
group: 'Pages',
|
|
102
|
+
breadcrumb: [title],
|
|
103
|
+
title: sub.title || title,
|
|
104
|
+
kind: 'anchor',
|
|
105
|
+
desc: stripHtml(sub.excerpt),
|
|
106
|
+
href: subHref,
|
|
107
|
+
});
|
|
108
|
+
if (out.length >= 12) break;
|
|
109
|
+
}
|
|
110
|
+
if (out.length >= 12) break;
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
package/src/runtime/App.jsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { Routes, Route, useLocation, Link } from 'react-router-dom';
|
|
2
|
+
import { Routes, Route, useLocation, useNavigate, Link } from 'react-router-dom';
|
|
3
3
|
import { MDXProvider } from '@mdx-js/react';
|
|
4
4
|
// The project's pages + navigation, generated from velu.json by
|
|
5
5
|
// vite-plugin-velu-site (see src/vite-plugin-velu-site.js). `pages` is
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
defaultMdxComponents,
|
|
40
40
|
resolveIcon,
|
|
41
41
|
Search,
|
|
42
|
+
pagefindSearch,
|
|
42
43
|
Logo,
|
|
43
44
|
SocialLinks,
|
|
44
45
|
Tree,
|
|
@@ -719,6 +720,7 @@ function DocsPage() {
|
|
|
719
720
|
// SSR and client compute these from the same pathname + the same
|
|
720
721
|
// shared `resolve()`, so the render is identical (hydration-safe).
|
|
721
722
|
const location = useLocation();
|
|
723
|
+
const navigate = useNavigate();
|
|
722
724
|
const pathname = normalizeUrl(location.pathname);
|
|
723
725
|
const entry = pages[pathname];
|
|
724
726
|
const nav = React.useMemo(
|
|
@@ -726,6 +728,22 @@ function DocsPage() {
|
|
|
726
728
|
[pathname],
|
|
727
729
|
);
|
|
728
730
|
|
|
731
|
+
// Search source: Pagefind for content/excerpts, but resolve each result's
|
|
732
|
+
// breadcrumb from the real NAVIGATION (a page's nav group can differ from its
|
|
733
|
+
// URL path — e.g. essentials/markdown.mdx lives under the "Writing Content"
|
|
734
|
+
// group). Page rows show the path to the page; anchor rows include the page.
|
|
735
|
+
const searchDocs = React.useCallback(async (q) => {
|
|
736
|
+
const results = await pagefindSearch(q);
|
|
737
|
+
if (!Array.isArray(results)) return results;
|
|
738
|
+
return results.map((r) => {
|
|
739
|
+
const res = resolve(r.href.split('#')[0], navigation, pages);
|
|
740
|
+
if (!res?.breadcrumb?.length) return r;
|
|
741
|
+
const labels = res.breadcrumb.map((c) => c.label);
|
|
742
|
+
const crumbs = r.kind === 'anchor' ? labels : labels.slice(0, -1);
|
|
743
|
+
return crumbs.length ? { ...r, breadcrumb: crumbs } : r;
|
|
744
|
+
});
|
|
745
|
+
}, []);
|
|
746
|
+
|
|
729
747
|
const frontmatter = entry?.frontmatter ?? {};
|
|
730
748
|
const PageComponent = entry?.Component ?? null;
|
|
731
749
|
const pageToc = entry?.toc ?? [];
|
|
@@ -1116,7 +1134,12 @@ function DocsPage() {
|
|
|
1116
1134
|
tabsTrailing={languageSwitcher || undefined}
|
|
1117
1135
|
center={
|
|
1118
1136
|
<Cluster space="var(--s-6)" align="center">
|
|
1119
|
-
<Search
|
|
1137
|
+
<Search
|
|
1138
|
+
style={{ inlineSize: '30ch' }}
|
|
1139
|
+
unavailable={IS_DEV_PREVIEW}
|
|
1140
|
+
search={IS_DEV_PREVIEW ? undefined : searchDocs}
|
|
1141
|
+
onSelect={(item) => item.href && navigate(item.href)}
|
|
1142
|
+
/>
|
|
1120
1143
|
{/* Ask AI talks to the deployed site's AI backend — hidden in
|
|
1121
1144
|
the local dev preview where there's nothing to talk to. */}
|
|
1122
1145
|
{!IS_DEV_PREVIEW && (
|
|
@@ -1276,6 +1299,11 @@ function DocsPage() {
|
|
|
1276
1299
|
)}
|
|
1277
1300
|
</Stack>
|
|
1278
1301
|
)}
|
|
1302
|
+
{/* Hairline separating the top anchor links from the nav sections
|
|
1303
|
+
(faithful to the sidebar design's anchor↔sidebar divider). */}
|
|
1304
|
+
{anchors.length > 0 && (
|
|
1305
|
+
<div className="velu-docs-context-divider" aria-hidden="true" />
|
|
1306
|
+
)}
|
|
1279
1307
|
{/* Only this region scrolls — the context zone above stays
|
|
1280
1308
|
pinned. The up/down arrows overlay its top/bottom edges and
|
|
1281
1309
|
appear (via the data-fade-* attrs the scroll handler sets)
|
|
@@ -1437,7 +1465,7 @@ function DocsPage() {
|
|
|
1437
1465
|
})}
|
|
1438
1466
|
</span>
|
|
1439
1467
|
</button>
|
|
1440
|
-
<div className="velu-docs-layout__article">
|
|
1468
|
+
<div className="velu-docs-layout__article" data-pagefind-body="">
|
|
1441
1469
|
{/* Per-page agent/IDE action bar: the section eyebrow + a
|
|
1442
1470
|
"Copy Page" split-button whose dropdown is driven by the
|
|
1443
1471
|
Mintlify-compatible `contextual` config. Renders nothing
|