chore: Rename CSRFence to CSRSnippet and add toast in Fence

This commit is contained in:
Gauthier Daniels 2025-04-13 16:05:56 +02:00
parent 7c83d3b37e
commit 3f6d324980
11 changed files with 240 additions and 40 deletions

View File

@ -3,9 +3,9 @@ import { prismThemes } from "@/data/themes/prism";
import { Highlight } from "prism-react-renderer"; import { Highlight } from "prism-react-renderer";
import { useTheme } from "@/hooks/useTheme"; import { useTheme } from "@/hooks/useTheme";
import { Fragment, useMemo } from "react"; import { Fragment, useMemo } from "react";
import { toast } from "react-toastify";
import { Button } from "./Button"; import { Button } from "./Button";
import Prism from "prismjs"; import Prism from "prismjs";
import { toast } from "react-toastify";
export default function CSRFence({ children, language }: { children: string; language: string }) { export default function CSRFence({ children, language }: { children: string; language: string }) {
const { theme } = useTheme(); const { theme } = useTheme();
@ -16,7 +16,7 @@ export default function CSRFence({ children, language }: { children: string; lan
const copyToClipboard = () => { const copyToClipboard = () => {
navigator.clipboard.writeText(children.trimEnd()); navigator.clipboard.writeText(children.trimEnd());
toast.success("Code copied to clipboard!"); toast.success("Code copié dans le presse-papier");
}; };
return ( return (
@ -39,6 +39,7 @@ export default function CSRFence({ children, language }: { children: string; lan
</pre> </pre>
)} )}
</Highlight> </Highlight>
<Button <Button
className="absolute top-2 right-2 w-8 h-8 aspect-square opacity-0 group-hover:opacity-50 hover:opacity-100 transition-opacity" className="absolute top-2 right-2 w-8 h-8 aspect-square opacity-0 group-hover:opacity-50 hover:opacity-100 transition-opacity"
size="sm" size="sm"

View File

@ -5,7 +5,7 @@ import { Fragment, useMemo } from "react";
import { clientOnly } from "vike-react/clientOnly"; import { clientOnly } from "vike-react/clientOnly";
const CSRFence = clientOnly(() => import("./CSRFence")); const CSRSnippet = clientOnly(() => import("./CSRSnippet"));
function SSRFence({ children, language }: { children: string; language: string }) { function SSRFence({ children, language }: { children: string; language: string }) {
const { theme } = useTheme(); const { theme } = useTheme();
@ -38,9 +38,9 @@ function SSRFence({ children, language }: { children: string; language: string }
export function Fence({ children, language }: { children: string; language: string }) { export function Fence({ children, language }: { children: string; language: string }) {
return ( return (
<div className="relative group"> <div className="relative group">
<CSRFence language={language} fallback={<SSRFence language={language} children={children} />}> <CSRSnippet language={language} fallback={<SSRFence language={language} children={children} />}>
{children} {children}
</CSRFence> </CSRSnippet>
</div> </div>
); );
} }

View File

@ -0,0 +1,58 @@
import type { Data } from "@/pages/docs/+data";
import { snippetsService } from "@/services/SnippetsService";
import { Highlight, Prism } from "prism-react-renderer";
import { clientOnly } from "vike-react/clientOnly";
import { prismThemes } from "@/data/themes/prism";
import { useData } from "vike-react/useData";
import { useTheme } from "@/hooks/useTheme";
import { Fragment, useMemo } from "react";
const CSRSnippet = clientOnly(() => import("./CSRSnippet"));
function SSRSnippet({ language, children }: { language: string; children: string }) {
const { theme } = useTheme();
const prismTheme = useMemo(() => {
return prismThemes[theme];
}, [theme]);
return (
<Highlight code={children.trimEnd()} language={language} theme={prismTheme} prism={Prism}>
{({ className, style, tokens, getTokenProps }) => (
<pre className={className} style={style}>
<code>
{tokens.map((line, lineIndex) => (
<Fragment key={lineIndex}>
{line
.filter((token) => !token.empty)
.map((token, tokenIndex) => (
<span key={tokenIndex} {...getTokenProps({ token })} />
))}
{"\n"}
</Fragment>
))}
</code>
</pre>
)}
</Highlight>
);
}
export function Snippet({ path, language, label }: { path: string; language: string; label?: string }) {
const { snippets } = useData<Data>();
const snippet = snippets.find((snippet) => snippet.path === path);
if (!snippet || !snippet.content) return null;
const props = {
language,
label,
children: snippet.content,
};
return (
<div className="relative group">
<CSRSnippet {...props} fallback={<SSRSnippet {...props} />} />
</div>
);
}

View File

@ -50,18 +50,7 @@ Parlons dans un premier temps de la signature d'un reducer :
{% tab value="jsx" label="JSX" %} {% tab value="jsx" label="JSX" %}
```jsx {% snippet path="data/docs/react/usereducer/reducer-example.jsx" language="jsx" label="test" /%}
const reducer = (state, action) => {
switch (action.type) {
case "TYPE_1":
return { ...state /* Nouvel état */ };
case "TYPE_2":
return { ...state /* Nouvel état */ };
default:
return state;
}
};
```
{% /tab %} {% /tab %}

View File

@ -0,0 +1,10 @@
const reducer = (state, action) => {
switch (action.type) {
case "TYPE_1":
return { ...state /* Nouvel état */ };
case "TYPE_2":
return { ...state /* Nouvel état */ };
default:
return state;
}
};

View File

@ -5,10 +5,6 @@ import { fileURLToPath } from "node:url";
import { dirname } from "node:path"; import { dirname } from "node:path";
import Fastify from "fastify"; import Fastify from "fastify";
import { Prism } from "prism-react-renderer";
(typeof global !== "undefined" ? global : window).Prism = Prism;
require("prismjs/components/prism-bash");
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);

View File

@ -14,6 +14,7 @@ import "./style.css";
import "./tailwind.css"; import "./tailwind.css";
import "./prism.css"; import "./prism.css";
import "unfonts.css"; import "unfonts.css";
import { ToastContainer } from "react-toastify";
function GitHubIcon(props: React.ComponentPropsWithoutRef<"svg">) { function GitHubIcon(props: React.ComponentPropsWithoutRef<"svg">) {
return ( return (
@ -93,6 +94,7 @@ export default function DefaultLayout({ children }: { children: React.ReactNode
{children} {children}
</div> </div>
</div> </div>
<ToastContainer />
</ThemeProvider> </ThemeProvider>
); );
} }

View File

@ -1,7 +1,14 @@
import { QuickLink, QuickLinks } from "@syntax/QuickLinks"; import { QuickLink, QuickLinks } from "@syntax/QuickLinks";
import { TabContent, Tabs } from "@/components/md/Tabs"; import { TabContent, Tabs } from "@/components/md/Tabs";
// import { Fence2 } from "@/components/syntax/Fence2";
import { Callout } from "@syntax/Callout"; import { Callout } from "@syntax/Callout";
// import fs from "fs/promises";
// import { Tag } from "./Tag";
import React from "react"; import React from "react";
import { Snippet } from "@/components/syntax/Snippet";
// import path from "path";
// const __dirname = path.resolve();
const tags = { const tags = {
callout: { callout: {
@ -67,6 +74,53 @@ const tags = {
value: { type: String }, value: { type: String },
}, },
}, },
snippet: {
render: Snippet,
attributes: {
language: {
type: String,
default: "auto",
},
label: { type: String },
path: { type: String },
},
},
// snippet: {
// // render: Fence2,
// attributes: {
// language: {
// type: String,
// default: "auto",
// },
// label: { type: String },
// description: { type: String },
// path: { type: String },
// },
// async transform(node: any, config: any) {
// const attributes = node.transformAttributes(config);
// const pathValue = attributes.path;
// let language = attributes.language ?? "auto";
// let content = "";
// if (!pathValue) {
// console.warn("No path provided for snippet tag");
// } else {
// const absolutePath = path.resolve(__dirname, pathValue);
// // Read the file content
// try {
// content = await fs.readFile(absolutePath, "utf-8");
// } catch (error) {
// console.error("Error reading file:", error);
// content = `Error reading file: ${absolutePath}`;
// language = "plain";
// }
// // return new Tag("fence2", { ...attributes, language, content, label: "Temp" });
// }
// },
// },
}; };
export default tags; export default tags;

View File

@ -1,5 +1,6 @@
import type { PageContext } from "vike/types"; import type { PageContext } from "vike/types";
import { snippetsService } from "@/services/SnippetsService";
import { docsService } from "@/services/DocsService"; import { docsService } from "@/services/DocsService";
import { readingTime } from "reading-time-estimator"; import { readingTime } from "reading-time-estimator";
import { useConfig } from "vike-react/useConfig"; import { useConfig } from "vike-react/useConfig";
@ -28,5 +29,10 @@ export async function data(pageContext: PageContext) {
docsService.transform(doc); docsService.transform(doc);
return { doc, estimatedReadingTime: readingTimeObject.text }; const snippets = Array.from(doc.snippets).map((snippetPath) => ({
path: snippetPath,
content: snippetsService.getFromCache(snippetPath),
}));
return { doc, estimatedReadingTime: readingTimeObject.text, snippets };
} }

View File

@ -1,5 +1,6 @@
import type { Node } from "@markdoc/markdoc"; import type { Node } from "@markdoc/markdoc";
import { snippetsService } from "@/services/SnippetsService";
import { slugifyWithCounter } from "@sindresorhus/slugify"; import { slugifyWithCounter } from "@sindresorhus/slugify";
import { buildFlexSearch } from "./FlexSearchService"; import { buildFlexSearch } from "./FlexSearchService";
import Markdoc from "@markdoc/markdoc"; import Markdoc from "@markdoc/markdoc";
@ -9,19 +10,22 @@ import glob from "fast-glob";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
const __dirname = path.resolve();
export type FlexSearchData = { key: string; sections: DocSection[] }[]; export type FlexSearchData = { key: string; sections: DocSection[] }[];
type DocsCache = Map<string, DocData>; type DocsCache = Map<string, DocData>;
type DocSection = [string, string | null, string[]]; type DocSection = [string, string | null, string[]];
type DocData = { title: string; description: string; content: string; sections: DocSection[] }; type DocData = { title: string; description: string; content: string; sections: DocSection[]; snippets: string[] };
type DocExtension = "mdx" | "md"; type DocExtension = "mdx" | "md";
class DocsService { class DocsService {
private static readonly DOCS_PATH = path.resolve("../../app/data"); private static readonly DOCS_PATH = path.resolve(path.join(__dirname, "data"));
private static readonly DOCS_EXTS: DocExtension[] = ["mdx", "md"]; // Order matters private static readonly DOCS_EXTS: DocExtension[] = ["mdx", "md"]; // Order matters
private static instance: DocsService; private static instance: DocsService;
public search: ReturnType<typeof buildFlexSearch> = buildFlexSearch([]); public search: ReturnType<typeof buildFlexSearch> = buildFlexSearch([]);
private slugify = slugifyWithCounter(); private slugify = slugifyWithCounter();
private cache: DocsCache = new Map(); private cache: DocsCache = new Map();
@ -78,9 +82,25 @@ class DocsService {
} }
} }
public fetchSnippets(content: string) {
const identifierResults = snippetsService.identifyNewSnippets(content);
if (!identifierResults) return;
const [snippetsToFetch, allSnippets] = identifierResults;
for (const snippet of snippetsToFetch) {
const absolutePath = path.resolve(__dirname, snippet);
const content = fs.readFileSync(absolutePath, "utf-8");
snippetsService.setToCache(snippet, content);
}
return allSnippets;
}
public async fetchDocs() { public async fetchDocs() {
const docs = glob.sync(DocsService.DOCS_PATH + `/**/*.{${DocsService.DOCS_EXTS.join(",")}}`); const docs = glob.sync(DocsService.DOCS_PATH + `/**/*.{${DocsService.DOCS_EXTS.join(",")}}`);
const data = docs.map((doc) => { const data = await Promise.all(
docs.map((doc) => {
const content = fs.readFileSync(doc, "utf-8"); const content = fs.readFileSync(doc, "utf-8");
const extension = path.extname(doc).slice(1) as DocExtension; const extension = path.extname(doc).slice(1) as DocExtension;
const key = doc const key = doc
@ -89,16 +109,19 @@ class DocsService {
.replace(`.${extension}`, "") .replace(`.${extension}`, "")
.replace(/\/$/g, ""); .replace(/\/$/g, "");
const allSnippets = this.fetchSnippets(content);
const ast = Markdoc.parse(content); const ast = Markdoc.parse(content);
const title = ast.attributes?.frontmatter?.match(/^title:\s*(.*?)\s*$/m)?.[1]; const title = ast.attributes?.frontmatter?.match(/^title:\s*(.*?)\s*$/m)?.[1];
const description = ast.attributes?.frontmatter?.match(/^description:\s*(.*?)\s*$/m)?.[1]?.replaceAll('"', ""); const description = ast.attributes?.frontmatter?.match(/^description:\s*(.*?)\s*$/m)?.[1]?.replaceAll('"', "");
const sections: DocSection[] = [[title, null, []]]; const sections: DocSection[] = [[title, null, []]];
this.extractSections(ast, sections); this.extractSections(ast, sections);
this.setToCache(key, { title, description, content, sections }); this.setToCache(key, { title, description, content, sections, snippets: allSnippets || [] });
return { key, sections }; return { key, sections };
}); }),
);
return data; return data;
} }

View File

@ -0,0 +1,61 @@
import path from "path";
type SnippetsCache = Map<string, string>;
class SnippetsService {
private static instance: SnippetsService;
private cache: SnippetsCache = new Map();
public static getInstance(): SnippetsService {
if (!SnippetsService.instance) {
SnippetsService.instance = new SnippetsService();
}
return SnippetsService.instance;
}
public getFromCache(key: string): string | undefined {
return this.cache.get(key);
}
private doesCacheHas(key: string): boolean {
return this.cache.has(key);
}
public setToCache(key: string, value: string): void {
this.cache.set(key, value);
}
public showCache(): SnippetsCache {
return this.cache;
}
public identifyNewSnippets(content: string): [string[], string[]] | undefined {
const regex = /{%\s?snippet.*(path="\S*").*\/%}/g;
const snippets = content.match(regex);
if (!snippets) return;
const snippetsPath = snippets
.map((snippet) => {
const pathMatch = snippet.match(/path="([^"]+)"/);
const path = pathMatch?.[1];
if (!path) return null;
return path;
})
.filter((path) => path !== null) as string[];
const snippetsPathSet = new Set(snippetsPath);
return [
Array.from(snippetsPathSet).filter((snippetPath) => {
return this.doesCacheHas(snippetPath) === false;
}),
Array.from(snippetsPathSet),
];
}
}
export const snippetsService = SnippetsService.getInstance();