Compare commits
5 Commits
9569049e61
...
d4df6f5c11
| Author | SHA1 | Date | |
|---|---|---|---|
| d4df6f5c11 | |||
| e54b07dfd2 | |||
| f42e2b5788 | |||
| b8a8213f27 | |||
| 0d7afbd9cf |
15
app/bun.lock
15
app/bun.lock
@ -5,6 +5,7 @@
|
||||
"dependencies": {
|
||||
"@fastify/middie": "^9.0.3",
|
||||
"@fastify/static": "^8.1.1",
|
||||
"@markdoc/markdoc": "^0.5.1",
|
||||
"@mdx-js/rollup": "^3.1.0",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
@ -12,12 +13,14 @@
|
||||
"@universal-middleware/fastify": "^0.5.16",
|
||||
"clsx": "^2.1.1",
|
||||
"fastify": "^5.3.0",
|
||||
"flexsearch": "^0.8.158",
|
||||
"js-yaml": "^4.1.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"reading-time-estimator": "^1.14.0",
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"remark-heading-id": "^1.0.1",
|
||||
"solid-heroicons": "^3.2.4",
|
||||
"solid-highlight-words": "^1.0.4",
|
||||
"solid-js": "^1.9.5",
|
||||
"solid-jsx": "^1.1.4",
|
||||
"solid-mdx": "^0.0.7",
|
||||
@ -226,6 +229,8 @@
|
||||
|
||||
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
|
||||
|
||||
"@markdoc/markdoc": ["@markdoc/markdoc@0.5.1", "", { "optionalDependencies": { "@types/linkify-it": "^3.0.1", "@types/markdown-it": "12.2.3" }, "peerDependencies": { "@types/react": "*", "react": "*" }, "optionalPeers": ["@types/react", "react"] }, "sha512-W2apYOglq0hOnvWbhE70yl6V9++FG+YPFKNHmgiSjv0HTmdJaMLt+NA1LMqoH5LasSiTI7R0yVc5ofjaFh39Pg=="],
|
||||
|
||||
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw=="],
|
||||
|
||||
"@mdx-js/rollup": ["@mdx-js/rollup@3.1.0", "", { "dependencies": { "@mdx-js/mdx": "^3.0.0", "@rollup/pluginutils": "^5.0.0", "source-map": "^0.7.0", "vfile": "^6.0.0" }, "peerDependencies": { "rollup": ">=2" } }, "sha512-q4xOtUXpCzeouE8GaJ8StT4rDxm/U5j6lkMHL2srb2Q3Y7cobE0aXyPzXVVlbeIMBi+5R5MpbiaVE5/vJUdnHg=="],
|
||||
@ -340,8 +345,14 @@
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/linkify-it": ["@types/linkify-it@3.0.5", "", {}, "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw=="],
|
||||
|
||||
"@types/markdown-it": ["@types/markdown-it@12.2.3", "", { "dependencies": { "@types/linkify-it": "*", "@types/mdurl": "*" } }, "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
|
||||
|
||||
"@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
@ -596,6 +607,8 @@
|
||||
|
||||
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"flexsearch": ["flexsearch@0.8.158", "", {}, "sha512-UBOzX2rxIrhAeSSCesTI0qB2Q+75n66rofJx5ppZm5tjXV2P6BxOS3VHKsoSdJhIPg9IMzQl3qkVeSFyq3BUdw=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
|
||||
@ -978,6 +991,8 @@
|
||||
|
||||
"solid-heroicons": ["solid-heroicons@3.2.4", "", { "dependencies": { "solid-js": "^1.7.6" } }, "sha512-u6BMdFLvkJnvUGYzdFcWp1wvJ4hb9Y1zd3AbZ9D3bUmmiy9jBzNZX+RcqBCI2EKRvdQwAb1UB9bkESfqfhayDg=="],
|
||||
|
||||
"solid-highlight-words": ["solid-highlight-words@1.0.4", "", { "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-Qxnc9W69HsGUl16wrpNwW3j3IvJaFVQjJM+BFfwU3WReSpPCzHSk7vUHzb9V1V3vh0azq1T73+OqtICmnSQ8CQ=="],
|
||||
|
||||
"solid-js": ["solid-js@1.9.5", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "^1.1.0", "seroval-plugins": "^1.1.0" } }, "sha512-ogI3DaFcyn6UhYhrgcyRAMbu/buBJitYQASZz5WzfQVPP10RD2AbCoRZ517psnezrasyCbWzIxZ6kVqet768xw=="],
|
||||
|
||||
"solid-jsx": ["solid-jsx@1.1.4", "", { "peerDependencies": { "solid-js": ">=1.4.0" } }, "sha512-A4E9cB+wZpHZrXzv3+OWr6zaGS0FjD/UAKqbI38R1JwogjlBXdSGC2PgaIMisnGYKL3oJ55FPLv4QRkENmdbWQ=="],
|
||||
|
||||
@ -313,10 +313,7 @@ export type Language = (typeof Language)[keyof typeof Language];
|
||||
|
||||
type Props = {
|
||||
language: string;
|
||||
} & (
|
||||
| (ComponentProps<"code"> & { code?: never })
|
||||
| (Omit<JSX.IntrinsicElements["code"], "children"> & { code: string })
|
||||
);
|
||||
} & ComponentProps<"code">;
|
||||
|
||||
export const Highlight: ParentComponent<Props> = (_props) => {
|
||||
const props = mergeProps({ language: "javascript" }, _props);
|
||||
@ -354,7 +351,7 @@ export const Highlight: ParentComponent<Props> = (_props) => {
|
||||
innerHTML={highlightedCode()}
|
||||
{...rest}
|
||||
>
|
||||
{props.code || props.children}
|
||||
{props.children}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
|
||||
@ -7,7 +7,8 @@ import { PluginsIcon } from "@/icons/PluginsIcon";
|
||||
import { PresetsIcon } from "@/icons/PresetsIcon";
|
||||
import { ThemingIcon } from "@/icons/ThemingIcon";
|
||||
import { WarningIcon } from "@/icons/WarningIcon";
|
||||
import { createUniqueId, For } from "solid-js";
|
||||
import { useId } from "@/hooks/useId";
|
||||
import { For } from "solid-js";
|
||||
import clsx from "clsx";
|
||||
|
||||
const icons = {
|
||||
@ -34,7 +35,7 @@ export type IconProps = JSX.IntrinsicElements["svg"] & {
|
||||
};
|
||||
|
||||
export function Icon(props: IconProps) {
|
||||
const id = createUniqueId();
|
||||
const id = useId();
|
||||
const IconComponent = icons[props.icon];
|
||||
|
||||
return (
|
||||
|
||||
@ -9,7 +9,7 @@ export function Image(props: ImageProps) {
|
||||
<img
|
||||
{...props}
|
||||
src={props.src}
|
||||
role={isDecorationImage ? "presentation" : "img"}
|
||||
role={isDecorationImage ? "presentation" : undefined}
|
||||
aria-hidden={isDecorationImage ? "true" : undefined}
|
||||
alt={isDecorationImage ? undefined : props.alt}
|
||||
loading="lazy"
|
||||
|
||||
@ -25,7 +25,7 @@ export function Link(props: LinkProps) {
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
{...(isActive && { ariaCurrent: "page" })}
|
||||
{...(isActive && { "aria-current": "page" })}
|
||||
{...(isDownload && { download: true })}
|
||||
{...(!isSameDomain || isDownload
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
import { createUniqueId } from "solid-js";
|
||||
import { useId } from "@/hooks/useId";
|
||||
|
||||
function LogomarkPaths() {
|
||||
const id = createUniqueId();
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -30,7 +30,7 @@ function PageLink(props: PageLinkProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div {...cleanProps(props, "dir", "title")}>
|
||||
<div {...cleanProps(props, "dir", "title", "href", "subitems")}>
|
||||
<dt class="font-display text-sm font-medium text-slate-900">
|
||||
{props.dir === "next" ? "Suivant" : "Précédent"}
|
||||
</dt>
|
||||
|
||||
9
app/components/Search.telefunc.ts
Normal file
9
app/components/Search.telefunc.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { buildFlexSearch } from "@/services/FlexSearchService";
|
||||
import { docCache } from "@/services/DocCache";
|
||||
|
||||
export const onSearch = async (query: string) => {
|
||||
const docs = docCache.fetchDocs();
|
||||
const search = buildFlexSearch(docs);
|
||||
|
||||
return search(query, 5);
|
||||
};
|
||||
329
app/components/Search.tsx
Normal file
329
app/components/Search.tsx
Normal file
@ -0,0 +1,329 @@
|
||||
import type { SearchResult } from "@/services/FlexSearchService";
|
||||
import type { JSX, Accessor, Setter } from "solid-js";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
For,
|
||||
createEffect,
|
||||
createSignal,
|
||||
} from "solid-js";
|
||||
import { Dialog, DialogPanel } from "terracotta";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { Highlighter } from "solid-highlight-words";
|
||||
import { navigate } from "vike/client/router";
|
||||
import { onSearch } from "./Search.telefunc";
|
||||
import { useId } from "@/hooks/useId";
|
||||
import clsx from "clsx";
|
||||
|
||||
const SearchContext = createContext<{
|
||||
query: Accessor<string>;
|
||||
close: () => void;
|
||||
results: Accessor<SearchResult[]>;
|
||||
isLoading: Accessor<boolean>;
|
||||
isOpened: Accessor<boolean>;
|
||||
setQuery: Setter<string>;
|
||||
setIsOpened: Setter<boolean>;
|
||||
setIsLoading: Setter<boolean>;
|
||||
setResults: Setter<SearchResult[]>;
|
||||
}>({
|
||||
query: () => "",
|
||||
close: () => {},
|
||||
results: () => [],
|
||||
isLoading: () => false,
|
||||
isOpened: () => false,
|
||||
setQuery: () => {},
|
||||
setIsOpened: () => {},
|
||||
setIsLoading: () => {},
|
||||
setResults: () => {},
|
||||
});
|
||||
|
||||
function SearchIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 20 20" {...props}>
|
||||
<path d="M16.293 17.707a1 1 0 0 0 1.414-1.414l-1.414 1.414ZM9 14a5 5 0 0 1-5-5H2a7 7 0 0 0 7 7v-2ZM4 9a5 5 0 0 1 5-5V2a7 7 0 0 0-7 7h2Zm5-5a5 5 0 0 1 5 5h2a7 7 0 0 0-7-7v2Zm8.707 12.293-3.757-3.757-1.414 1.414 3.757 3.757 1.414-1.414ZM14 9a4.98 4.98 0 0 1-1.464 3.536l1.414 1.414A6.98 6.98 0 0 0 16 9h-2Zm-1.464 3.536A4.98 4.98 0 0 1 9 14v2a6.98 6.98 0 0 0 4.95-2.05l-1.414-1.414Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||
<circle cx="10" cy="10" r="5.5" stroke-linejoin="round" />
|
||||
<path
|
||||
stroke={`url(#${id})`}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.5 10a5.5 5.5 0 1 0-5.5 5.5"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={id}
|
||||
x1="13"
|
||||
x2="9.5"
|
||||
y1="9"
|
||||
y2="15"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="currentColor" />
|
||||
<stop offset="1" stop-color="currentColor" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchInput() {
|
||||
const { close, setQuery, query, isLoading } = useContext(SearchContext);
|
||||
|
||||
return (
|
||||
<div class="group relative flex h-12">
|
||||
<SearchIcon class="pointer-events-none absolute top-0 left-4 h-full w-5 fill-slate-400" />
|
||||
<input
|
||||
data-autofocus
|
||||
class={clsx(
|
||||
"flex-auto appearance-none bg-transparent pl-12 text-slate-900 outline-hidden placeholder:text-slate-400 focus:w-full focus:flex-none sm:text-sm [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden [&::-webkit-search-results-button]:hidden [&::-webkit-search-results-decoration]:hidden",
|
||||
isLoading() ? "pr-11" : "pr-4",
|
||||
)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
// In Safari, closing the dialog with the escape key can sometimes cause the scroll position to jump to the
|
||||
// bottom of the page. This is a workaround for that until we can figure out a proper fix in Headless UI.
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
close();
|
||||
}
|
||||
}}
|
||||
value={query()}
|
||||
onInput={(event) => {
|
||||
const { value } = event.currentTarget;
|
||||
setQuery(value);
|
||||
}}
|
||||
/>
|
||||
{isLoading() && (
|
||||
<div class="absolute inset-y-0 right-3 flex items-center">
|
||||
<LoadingIcon class="h-6 w-6 animate-spin stroke-slate-200 text-slate-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HighlightQuery({ text, query }: { text: string; query: string }) {
|
||||
return (
|
||||
<Highlighter
|
||||
highlightClass="group-aria-selected:underline bg-transparent text-violet-600"
|
||||
searchWords={[query]}
|
||||
autoEscape={true}
|
||||
textToHighlight={text}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultItem(props: { result: SearchResult; query: string }) {
|
||||
const { close } = useContext(SearchContext);
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<li
|
||||
class="group block cursor-default rounded-lg px-3 py-2 aria-selected:bg-slate-100 hover:bg-slate-100"
|
||||
aria-labelledby={`${id}-hierarchy ${id}-title`}
|
||||
tab-index={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
navigate(props.result.url);
|
||||
close();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
navigate(props.result.url);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id={`${id}-title`}
|
||||
aria-hidden="true"
|
||||
class="text-sm text-slate-700 group-aria-selected:text-violet-600"
|
||||
>
|
||||
<HighlightQuery text={props.result.title} query={props.query} />
|
||||
</div>
|
||||
{/* {props.result.length > 0 && (
|
||||
<div
|
||||
id={`${id}-hierarchy`}
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 truncate text-xs whitespace-nowrap text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
{hierarchy.map((item, itemIndex, items) => (
|
||||
<Fragment key={itemIndex}>
|
||||
<HighlightQuery text={item} query={query} />
|
||||
<span
|
||||
class={
|
||||
itemIndex === items.length - 1
|
||||
? "sr-only"
|
||||
: "mx-2 text-slate-300 dark:text-slate-700"
|
||||
}
|
||||
>
|
||||
/
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)} */}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResults() {
|
||||
const { results, query } = useContext(SearchContext);
|
||||
|
||||
if (results().length === 0) {
|
||||
return (
|
||||
<p class="px-4 py-8 text-center text-sm text-slate-700">
|
||||
Aucun résultat pour “
|
||||
<span class="break-words text-slate-900">{query()}</span>
|
||||
”
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul>
|
||||
<For each={results()}>
|
||||
{(result) => (
|
||||
<li>
|
||||
<SearchResultItem result={result} query={query()} />
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchDialog(props: { class?: string }) {
|
||||
const { close, isOpened, setIsOpened, results } = useContext(SearchContext);
|
||||
|
||||
createEffect(() => {
|
||||
if (isOpened()) return;
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
setIsOpened(true);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [isOpened, setIsOpened]);
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const { target, currentTarget } = event;
|
||||
|
||||
if (target instanceof Node && currentTarget instanceof Node) {
|
||||
if (target === currentTarget) close();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
isOpen={isOpened()}
|
||||
onClose={close}
|
||||
class={clsx("fixed inset-0 z-50", props.class)}
|
||||
>
|
||||
<div class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm" />
|
||||
|
||||
<div
|
||||
class="fixed inset-0 overflow-y-auto px-4 py-4 sm:px-6 sm:py-20 md:py-32 lg:px-8 lg:py-[15vh]"
|
||||
onClick={handleClickOutside}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") close();
|
||||
}}
|
||||
>
|
||||
<DialogPanel class="mx-auto transform-gpu overflow-hidden rounded-xl bg-white shadow-xl sm:max-w-xl">
|
||||
<form onSubmit={(event) => event.preventDefault()}>
|
||||
<SearchInput />
|
||||
<div class="border-t border-slate-200 bg-white px-2 py-3 empty:hidden">
|
||||
{results().length > 0 && <SearchResults />}
|
||||
</div>
|
||||
</form>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Search() {
|
||||
const [results, setResults] = createSignal<SearchResult[]>([]);
|
||||
const [modifierKey, setModifierKey] = createSignal<string>();
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const [isOpened, setIsOpened] = createSignal(false);
|
||||
const [query, setQuery] = createSignal("");
|
||||
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
|
||||
createEffect(() => {
|
||||
const platform = navigator.userAgentData?.platform || navigator.platform;
|
||||
setModifierKey(/(Mac|iPhone|iPod|iPad)/i.test(platform) ? "⌘" : "Ctrl ");
|
||||
}, []);
|
||||
|
||||
createEffect(() => {
|
||||
const query = debouncedQuery();
|
||||
|
||||
if (query.length === 0) {
|
||||
setIsLoading(false);
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
onSearch(query)
|
||||
.then(setResults)
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<SearchContext.Provider
|
||||
value={{
|
||||
query,
|
||||
close: () => setIsOpened(false),
|
||||
results,
|
||||
isLoading,
|
||||
isOpened,
|
||||
setQuery,
|
||||
setIsOpened,
|
||||
setIsLoading,
|
||||
setResults,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group flex h-6 w-6 items-center justify-center sm:justify-start md:h-auto md:w-80 md:flex-none md:rounded-lg md:py-2.5 md:pr-3.5 md:pl-4 md:text-sm md:ring-1 md:ring-slate-200 md:hover:ring-slate-300 lg:w-96"
|
||||
onClick={() => setIsOpened(true)}
|
||||
>
|
||||
<SearchIcon class="h-5 w-5 flex-none fill-slate-400 group-hover:fill-slate-500 md:group-hover:fill-slate-400" />
|
||||
<span class="sr-only md:not-sr-only md:ml-2 md:text-slate-500">
|
||||
Rechercher...
|
||||
</span>
|
||||
{modifierKey && (
|
||||
<kbd class="ml-auto hidden font-medium text-slate-400 md:block">
|
||||
<kbd class="font-sans">{modifierKey()}</kbd>
|
||||
<kbd class="font-sans">K</kbd>
|
||||
</kbd>
|
||||
)}
|
||||
</button>
|
||||
<SearchDialog />
|
||||
</SearchContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -5,10 +5,12 @@ import { createHandler } from "@universal-middleware/fastify";
|
||||
import { telefuncHandler } from "./server/telefunc-handler";
|
||||
import { vikeHandler } from "./server/vike-handler";
|
||||
import { createDevMiddleware } from "vike/server";
|
||||
import { docCache } from "./services/DocCache";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
import { config } from "./config";
|
||||
import Fastify from "fastify";
|
||||
import { buildFlexSearch } from "./services/FlexSearchService";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@ -32,6 +34,8 @@ declare global {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
await docCache.waitingForCache(20000);
|
||||
|
||||
const app = Fastify();
|
||||
|
||||
// Avoid pre-parsing body, otherwise it will cause issue with universal handlers
|
||||
|
||||
17
app/hooks/useDebounce.tsx
Normal file
17
app/hooks/useDebounce.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { createSignal, createEffect, onCleanup } from "solid-js";
|
||||
import type { Accessor } from "solid-js";
|
||||
|
||||
export function useDebounce<T>(value: Accessor<T>, delay = 1000): Accessor<T> {
|
||||
const [debouncedValue, setDebouncedValue] = createSignal(value());
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
createEffect(() => {
|
||||
value();
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => setDebouncedValue(() => value()), delay);
|
||||
});
|
||||
|
||||
onCleanup(() => clearTimeout(timeoutId));
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
6
app/hooks/useId.tsx
Normal file
6
app/hooks/useId.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import { createUniqueId } from "solid-js";
|
||||
|
||||
export function useId() {
|
||||
const id = createUniqueId();
|
||||
return `memento-${id}`;
|
||||
}
|
||||
@ -3,7 +3,8 @@ import type { JSX, JSXElement } from "solid-js";
|
||||
// import { CookiesContainer } from "@/components/common/Cookies";
|
||||
import { MobileNavigation } from "@/partials/MobileNavigation";
|
||||
import { usePageContext } from "vike-solid/usePageContext";
|
||||
// import { clientOnly } from "vike-react/clientOnly";
|
||||
// import { clientOnly } from "vike-solid/clientOnly";
|
||||
import { Search } from "@/components/Search";
|
||||
import { createEffect, createSignal } from "solid-js";
|
||||
import { HeroSection } from "@/partials/HeroSection";
|
||||
import { Navigation } from "@/partials/Navigation";
|
||||
@ -15,11 +16,9 @@ import clsx from "clsx";
|
||||
|
||||
import "./tailwind.css";
|
||||
|
||||
// const Search = clientOnly(() => import("@/components/Search").then((module) => module.Search));
|
||||
|
||||
function Search() {
|
||||
return null;
|
||||
}
|
||||
// const Search = clientOnly(() =>
|
||||
// import("@/components/Search").then((module) => module.Search),
|
||||
// );
|
||||
|
||||
function GitHubIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||
return (
|
||||
@ -63,8 +62,11 @@ function Header() {
|
||||
</div>
|
||||
|
||||
<div class="-my-5 mr-6 sm:mr-8 md:mr-0">
|
||||
{/* <Search fallback={<div class="h-6 w-6 animate-pulse rounded-full bg-slate-200" />} /> */}
|
||||
<div class="h-6 w-6 animate-pulse rounded-full bg-slate-200" />
|
||||
<Search
|
||||
// fallback={
|
||||
// <div class="h-6 w-6 animate-pulse rounded-full bg-slate-200" />
|
||||
// }
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="relative flex basis-0 justify-end gap-6 sm:gap-8 md:grow">
|
||||
@ -85,10 +87,10 @@ function Footer() {
|
||||
<footer class="bg-slate-50 text-slate-700">
|
||||
<div class="mx-auto w-full flex flex-col max-w-8xl sm:px-2 lg:px-8 xl:px-12 py-8">
|
||||
<section>
|
||||
<header class="flex items-center gap-2 mb-2">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Logo class="h-8 w-auto" />
|
||||
<h2 class="font-display text-2xl">Memento Dev</h2>
|
||||
</header>
|
||||
<strong class="font-display text-2xl">Memento Dev</strong>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Plateforme de ressources et documentations synthétiques et concises,
|
||||
@ -101,10 +103,10 @@ function Footer() {
|
||||
|
||||
<section>
|
||||
<header class="flex items-center gap-2">
|
||||
<h2 class="font-display">
|
||||
<strong class="font-display">
|
||||
© 2022 - {new Date().getFullYear()} Memento Dev. Tous droits
|
||||
réservés
|
||||
</h2>
|
||||
</strong>
|
||||
</header>
|
||||
|
||||
<p class="text-sm text-slate-500">
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@fastify/middie": "^9.0.3",
|
||||
"@fastify/static": "^8.1.1",
|
||||
"@markdoc/markdoc": "^0.5.1",
|
||||
"@mdx-js/rollup": "^3.1.0",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
@ -17,12 +18,14 @@
|
||||
"@universal-middleware/fastify": "^0.5.16",
|
||||
"clsx": "^2.1.1",
|
||||
"fastify": "^5.3.0",
|
||||
"flexsearch": "^0.8.158",
|
||||
"js-yaml": "^4.1.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"reading-time-estimator": "^1.14.0",
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"remark-heading-id": "^1.0.1",
|
||||
"solid-heroicons": "^3.2.4",
|
||||
"solid-highlight-words": "^1.0.4",
|
||||
"solid-js": "^1.9.5",
|
||||
"solid-jsx": "^1.1.4",
|
||||
"solid-mdx": "^0.0.7",
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
import { createUniqueId } from "solid-js";
|
||||
import { useId } from "@/hooks/useId";
|
||||
|
||||
export function HeroBackground(props: JSX.IntrinsicElements["svg"]) {
|
||||
const id = createUniqueId();
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<svg
|
||||
|
||||
@ -132,7 +132,7 @@ export function HeroSection() {
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Highlight language={codeLanguage} code={code} />
|
||||
<Highlight language={codeLanguage}>{code}</Highlight>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
185
app/services/DocCache.ts
Normal file
185
app/services/DocCache.ts
Normal file
@ -0,0 +1,185 @@
|
||||
import type { FlexSearchData } from "./FlexSearchService";
|
||||
import type { Node } from "@markdoc/markdoc";
|
||||
|
||||
import { slugifyWithCounter } from "@sindresorhus/slugify";
|
||||
import Markdoc from "@markdoc/markdoc";
|
||||
import { hrtime } from "node:process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import yaml from "js-yaml";
|
||||
import { copyFile } from "node:fs";
|
||||
|
||||
const __dirname = path.resolve();
|
||||
|
||||
export type SectionCache = {
|
||||
content: string;
|
||||
frontmatter?: SectionFrontmatter;
|
||||
markdocNode: Node;
|
||||
};
|
||||
|
||||
export type DocSection = {
|
||||
content: string;
|
||||
hash?: string;
|
||||
subsections: string[];
|
||||
};
|
||||
|
||||
type SectionFrontmatter = {
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
class DocCache {
|
||||
private static readonly pagesDir = path.join(__dirname, "pages");
|
||||
private static instance: DocCache | null = null;
|
||||
|
||||
private cache: Map<string, SectionCache> = new Map();
|
||||
private slugify = slugifyWithCounter();
|
||||
private cacheReady = false;
|
||||
|
||||
private constructor() {
|
||||
this.populateCache();
|
||||
}
|
||||
|
||||
private async getFiles(): Promise<string[]> {
|
||||
const files = await fs.readdir(DocCache.pagesDir, { recursive: true });
|
||||
return files.filter(
|
||||
(file) => file.endsWith(".md") || file.endsWith(".mdx"),
|
||||
);
|
||||
}
|
||||
|
||||
private removeFrontmatter(content: string): string {
|
||||
const frontmatterRegex = /^---\n[\s\S]*?\n---/;
|
||||
return content.replace(frontmatterRegex, "").trim();
|
||||
}
|
||||
|
||||
private async getFileContent(
|
||||
file: string,
|
||||
): Promise<[string, SectionFrontmatter | undefined, Node]> {
|
||||
const filePath = path.join(DocCache.pagesDir, file);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
const frontmatter = yaml.load(
|
||||
content.split("---")[1] || "",
|
||||
) as SectionFrontmatter;
|
||||
return [
|
||||
this.removeFrontmatter(content),
|
||||
frontmatter,
|
||||
Markdoc.parse(content),
|
||||
];
|
||||
} catch (error) {
|
||||
console.error(`Error reading file ${filePath}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private nodeToString(node: Node): string {
|
||||
let string = "";
|
||||
|
||||
if (node.type === "text" && typeof node.attributes?.content === "string") {
|
||||
string = node.attributes.content;
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
string += this.nodeToString(child);
|
||||
}
|
||||
}
|
||||
|
||||
return string;
|
||||
}
|
||||
|
||||
private extractSections(node: Node, sections: DocSection[], isRoot = true) {
|
||||
if (isRoot) this.slugify.reset();
|
||||
|
||||
if (["heading", "paragraph"].includes(node.type)) {
|
||||
const content = this.nodeToString(node).trim();
|
||||
|
||||
if (node.type === "heading" && node.attributes?.level <= 2) {
|
||||
const hash = (node.attributes?.id as string) ?? this.slugify(content);
|
||||
const subsections: string[] = [];
|
||||
sections.push({ content, hash, subsections });
|
||||
} else {
|
||||
sections.at(-1)?.subsections.push(content);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
this.extractSections(child, sections, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public fetchDocs() {
|
||||
const data = Array.from(this.cache.entries()).map(([key, section]) => {
|
||||
const sections: DocSection[] = [];
|
||||
this.extractSections(section.markdocNode, sections);
|
||||
return { key, sections };
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private async cacheFile(file: string): Promise<void> {
|
||||
const [content, frontmatter, markdocNode] = await this.getFileContent(file);
|
||||
this.cache.set(file, { content, frontmatter, markdocNode });
|
||||
}
|
||||
|
||||
private async populateCache(): Promise<void> {
|
||||
try {
|
||||
const files = await this.getFiles();
|
||||
|
||||
console.log(`Found ${files.length} files to cache`);
|
||||
|
||||
const cachePromises = files.map(async (file) => {
|
||||
return await this.cacheFile(file);
|
||||
});
|
||||
|
||||
await Promise.all(cachePromises);
|
||||
this.cacheReady = true;
|
||||
} catch (error) {
|
||||
console.error("Error populating cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
public static getInstance(): DocCache {
|
||||
if (!DocCache.instance) {
|
||||
DocCache.instance = new DocCache();
|
||||
}
|
||||
return DocCache.instance;
|
||||
}
|
||||
|
||||
public static getCache(): Map<string, SectionCache> {
|
||||
return DocCache.getInstance().cache;
|
||||
}
|
||||
|
||||
public get(file: string): SectionCache | undefined {
|
||||
return this.cache.get(file);
|
||||
}
|
||||
|
||||
public waitingForCache(timeout: 20000): Promise<void> {
|
||||
const timer = hrtime();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const interval = setInterval(() => {
|
||||
if (this.cacheReady) {
|
||||
const elapsed = hrtime(timer);
|
||||
console.log(`Cache ready in ${elapsed[0]}s ${elapsed[1] / 1e6}ms`);
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
reject(new Error("Cache not ready within timeout"));
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const docCache = DocCache.getInstance();
|
||||
75
app/services/FlexSearchService.ts
Normal file
75
app/services/FlexSearchService.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import type { DocSection } from "./DocCache";
|
||||
|
||||
export type FlexSearchData = { key: string; sections: DocSection[] }[];
|
||||
|
||||
import FlexSearch from "flexsearch";
|
||||
|
||||
interface NativeSearchResult {
|
||||
id: string;
|
||||
doc: {
|
||||
title: string;
|
||||
pageTitle?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type SearchResult = {
|
||||
url: string;
|
||||
title: string;
|
||||
pageTitle?: string;
|
||||
};
|
||||
|
||||
export function buildFlexSearch(data: FlexSearchData) {
|
||||
const sectionIndex = new FlexSearch.Document({
|
||||
tokenize: "full",
|
||||
document: {
|
||||
id: "url",
|
||||
index: "content",
|
||||
store: ["title", "pageTitle"],
|
||||
},
|
||||
context: {
|
||||
resolution: 9,
|
||||
depth: 2,
|
||||
bidirectional: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const { key, sections } of data) {
|
||||
for (const section of sections) {
|
||||
const { content, hash, subsections } = section;
|
||||
|
||||
const url = key.replace("index", "").replace(/(\+Page)?.md(x)?$/, "");
|
||||
|
||||
sectionIndex.add({
|
||||
url: url + (hash ? `#${hash}` : ""),
|
||||
title: content,
|
||||
content: [content, ...subsections].join("\n"),
|
||||
// @ts-ignore
|
||||
pageTitle: hash ? sections[0]?.content : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return function search(
|
||||
query: string,
|
||||
limit?: number,
|
||||
): Promise<SearchResult[]> {
|
||||
const result = sectionIndex.search(query, 5, {
|
||||
enrich: true,
|
||||
limit,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
if (result.length === 0) return [];
|
||||
|
||||
// @ts-ignore
|
||||
return result[0].result.map((rawItem) => {
|
||||
const item = rawItem as unknown as NativeSearchResult;
|
||||
|
||||
return {
|
||||
url: item.id,
|
||||
title: item.doc.title,
|
||||
pageTitle: item.doc.pageTitle,
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user