Compare commits

..

5 Commits

18 changed files with 671 additions and 28 deletions

View File

@ -5,6 +5,7 @@
"dependencies": { "dependencies": {
"@fastify/middie": "^9.0.3", "@fastify/middie": "^9.0.3",
"@fastify/static": "^8.1.1", "@fastify/static": "^8.1.1",
"@markdoc/markdoc": "^0.5.1",
"@mdx-js/rollup": "^3.1.0", "@mdx-js/rollup": "^3.1.0",
"@sindresorhus/slugify": "^2.2.1", "@sindresorhus/slugify": "^2.2.1",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
@ -12,12 +13,14 @@
"@universal-middleware/fastify": "^0.5.16", "@universal-middleware/fastify": "^0.5.16",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"fastify": "^5.3.0", "fastify": "^5.3.0",
"flexsearch": "^0.8.158",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"reading-time-estimator": "^1.14.0", "reading-time-estimator": "^1.14.0",
"remark-frontmatter": "^5.0.0", "remark-frontmatter": "^5.0.0",
"remark-heading-id": "^1.0.1", "remark-heading-id": "^1.0.1",
"solid-heroicons": "^3.2.4", "solid-heroicons": "^3.2.4",
"solid-highlight-words": "^1.0.4",
"solid-js": "^1.9.5", "solid-js": "^1.9.5",
"solid-jsx": "^1.1.4", "solid-jsx": "^1.1.4",
"solid-mdx": "^0.0.7", "solid-mdx": "^0.0.7",
@ -226,6 +229,8 @@
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], "@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/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=="], "@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/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/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/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@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=="], "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=="], "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=="], "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-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-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=="], "solid-jsx": ["solid-jsx@1.1.4", "", { "peerDependencies": { "solid-js": ">=1.4.0" } }, "sha512-A4E9cB+wZpHZrXzv3+OWr6zaGS0FjD/UAKqbI38R1JwogjlBXdSGC2PgaIMisnGYKL3oJ55FPLv4QRkENmdbWQ=="],

View File

@ -313,10 +313,7 @@ export type Language = (typeof Language)[keyof typeof Language];
type Props = { type Props = {
language: string; language: string;
} & ( } & ComponentProps<"code">;
| (ComponentProps<"code"> & { code?: never })
| (Omit<JSX.IntrinsicElements["code"], "children"> & { code: string })
);
export const Highlight: ParentComponent<Props> = (_props) => { export const Highlight: ParentComponent<Props> = (_props) => {
const props = mergeProps({ language: "javascript" }, _props); const props = mergeProps({ language: "javascript" }, _props);
@ -354,7 +351,7 @@ export const Highlight: ParentComponent<Props> = (_props) => {
innerHTML={highlightedCode()} innerHTML={highlightedCode()}
{...rest} {...rest}
> >
{props.code || props.children} {props.children}
</code> </code>
</pre> </pre>
); );

View File

@ -7,7 +7,8 @@ import { PluginsIcon } from "@/icons/PluginsIcon";
import { PresetsIcon } from "@/icons/PresetsIcon"; import { PresetsIcon } from "@/icons/PresetsIcon";
import { ThemingIcon } from "@/icons/ThemingIcon"; import { ThemingIcon } from "@/icons/ThemingIcon";
import { WarningIcon } from "@/icons/WarningIcon"; 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"; import clsx from "clsx";
const icons = { const icons = {
@ -34,7 +35,7 @@ export type IconProps = JSX.IntrinsicElements["svg"] & {
}; };
export function Icon(props: IconProps) { export function Icon(props: IconProps) {
const id = createUniqueId(); const id = useId();
const IconComponent = icons[props.icon]; const IconComponent = icons[props.icon];
return ( return (

View File

@ -9,7 +9,7 @@ export function Image(props: ImageProps) {
<img <img
{...props} {...props}
src={props.src} src={props.src}
role={isDecorationImage ? "presentation" : "img"} role={isDecorationImage ? "presentation" : undefined}
aria-hidden={isDecorationImage ? "true" : undefined} aria-hidden={isDecorationImage ? "true" : undefined}
alt={isDecorationImage ? undefined : props.alt} alt={isDecorationImage ? undefined : props.alt}
loading="lazy" loading="lazy"

View File

@ -25,7 +25,7 @@ export function Link(props: LinkProps) {
return ( return (
<a <a
{...props} {...props}
{...(isActive && { ariaCurrent: "page" })} {...(isActive && { "aria-current": "page" })}
{...(isDownload && { download: true })} {...(isDownload && { download: true })}
{...(!isSameDomain || isDownload {...(!isSameDomain || isDownload
? { target: "_blank", rel: "noopener noreferrer" } ? { target: "_blank", rel: "noopener noreferrer" }

View File

@ -1,9 +1,9 @@
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import { createUniqueId } from "solid-js"; import { useId } from "@/hooks/useId";
function LogomarkPaths() { function LogomarkPaths() {
const id = createUniqueId(); const id = useId();
return ( return (
<> <>

View File

@ -30,7 +30,7 @@ function PageLink(props: PageLinkProps) {
}); });
return ( return (
<div {...cleanProps(props, "dir", "title")}> <div {...cleanProps(props, "dir", "title", "href", "subitems")}>
<dt class="font-display text-sm font-medium text-slate-900"> <dt class="font-display text-sm font-medium text-slate-900">
{props.dir === "next" ? "Suivant" : "Précédent"} {props.dir === "next" ? "Suivant" : "Précédent"}
</dt> </dt>

View 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
View 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 &ldquo;
<span class="break-words text-slate-900">{query()}</span>
&rdquo;
</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>
);
}

View File

@ -5,10 +5,12 @@ import { createHandler } from "@universal-middleware/fastify";
import { telefuncHandler } from "./server/telefunc-handler"; import { telefuncHandler } from "./server/telefunc-handler";
import { vikeHandler } from "./server/vike-handler"; import { vikeHandler } from "./server/vike-handler";
import { createDevMiddleware } from "vike/server"; import { createDevMiddleware } from "vike/server";
import { docCache } from "./services/DocCache";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { config } from "./config"; import { config } from "./config";
import Fastify from "fastify"; import Fastify from "fastify";
import { buildFlexSearch } from "./services/FlexSearchService";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@ -32,6 +34,8 @@ declare global {
} }
async function startServer() { async function startServer() {
await docCache.waitingForCache(20000);
const app = Fastify(); const app = Fastify();
// Avoid pre-parsing body, otherwise it will cause issue with universal handlers // Avoid pre-parsing body, otherwise it will cause issue with universal handlers

17
app/hooks/useDebounce.tsx Normal file
View 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
View File

@ -0,0 +1,6 @@
import { createUniqueId } from "solid-js";
export function useId() {
const id = createUniqueId();
return `memento-${id}`;
}

View File

@ -3,7 +3,8 @@ import type { JSX, JSXElement } from "solid-js";
// import { CookiesContainer } from "@/components/common/Cookies"; // import { CookiesContainer } from "@/components/common/Cookies";
import { MobileNavigation } from "@/partials/MobileNavigation"; import { MobileNavigation } from "@/partials/MobileNavigation";
import { usePageContext } from "vike-solid/usePageContext"; 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 { createEffect, createSignal } from "solid-js";
import { HeroSection } from "@/partials/HeroSection"; import { HeroSection } from "@/partials/HeroSection";
import { Navigation } from "@/partials/Navigation"; import { Navigation } from "@/partials/Navigation";
@ -15,11 +16,9 @@ import clsx from "clsx";
import "./tailwind.css"; import "./tailwind.css";
// const Search = clientOnly(() => import("@/components/Search").then((module) => module.Search)); // const Search = clientOnly(() =>
// import("@/components/Search").then((module) => module.Search),
function Search() { // );
return null;
}
function GitHubIcon(props: JSX.IntrinsicElements["svg"]) { function GitHubIcon(props: JSX.IntrinsicElements["svg"]) {
return ( return (
@ -63,8 +62,11 @@ function Header() {
</div> </div>
<div class="-my-5 mr-6 sm:mr-8 md:mr-0"> <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" />} /> */} <Search
<div class="h-6 w-6 animate-pulse rounded-full bg-slate-200" /> // fallback={
// <div class="h-6 w-6 animate-pulse rounded-full bg-slate-200" />
// }
/>
</div> </div>
<div class="relative flex basis-0 justify-end gap-6 sm:gap-8 md:grow"> <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"> <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"> <div class="mx-auto w-full flex flex-col max-w-8xl sm:px-2 lg:px-8 xl:px-12 py-8">
<section> <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" /> <Logo class="h-8 w-auto" />
<h2 class="font-display text-2xl">Memento Dev</h2> <strong class="font-display text-2xl">Memento Dev</strong>
</header> </div>
<p> <p>
Plateforme de ressources et documentations synthétiques et concises, Plateforme de ressources et documentations synthétiques et concises,
@ -101,10 +103,10 @@ function Footer() {
<section> <section>
<header class="flex items-center gap-2"> <header class="flex items-center gap-2">
<h2 class="font-display"> <strong class="font-display">
&copy; 2022 - {new Date().getFullYear()} Memento Dev. Tous droits &copy; 2022 - {new Date().getFullYear()} Memento Dev. Tous droits
réservés réservés
</h2> </strong>
</header> </header>
<p class="text-sm text-slate-500"> <p class="text-sm text-slate-500">

View File

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@fastify/middie": "^9.0.3", "@fastify/middie": "^9.0.3",
"@fastify/static": "^8.1.1", "@fastify/static": "^8.1.1",
"@markdoc/markdoc": "^0.5.1",
"@mdx-js/rollup": "^3.1.0", "@mdx-js/rollup": "^3.1.0",
"@sindresorhus/slugify": "^2.2.1", "@sindresorhus/slugify": "^2.2.1",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
@ -17,12 +18,14 @@
"@universal-middleware/fastify": "^0.5.16", "@universal-middleware/fastify": "^0.5.16",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"fastify": "^5.3.0", "fastify": "^5.3.0",
"flexsearch": "^0.8.158",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"reading-time-estimator": "^1.14.0", "reading-time-estimator": "^1.14.0",
"remark-frontmatter": "^5.0.0", "remark-frontmatter": "^5.0.0",
"remark-heading-id": "^1.0.1", "remark-heading-id": "^1.0.1",
"solid-heroicons": "^3.2.4", "solid-heroicons": "^3.2.4",
"solid-highlight-words": "^1.0.4",
"solid-js": "^1.9.5", "solid-js": "^1.9.5",
"solid-jsx": "^1.1.4", "solid-jsx": "^1.1.4",
"solid-mdx": "^0.0.7", "solid-mdx": "^0.0.7",

View File

@ -1,9 +1,9 @@
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import { createUniqueId } from "solid-js"; import { useId } from "@/hooks/useId";
export function HeroBackground(props: JSX.IntrinsicElements["svg"]) { export function HeroBackground(props: JSX.IntrinsicElements["svg"]) {
const id = createUniqueId(); const id = useId();
return ( return (
<svg <svg

View File

@ -132,7 +132,7 @@ export function HeroSection() {
</For> </For>
</div> </div>
<Highlight language={codeLanguage} code={code} /> <Highlight language={codeLanguage}>{code}</Highlight>
</div> </div>
</div> </div>
</div> </div>

185
app/services/DocCache.ts Normal file
View 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();

View 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,
};
});
};
}