diff --git a/app/build-sitemap.ts b/app/build-sitemap.ts
new file mode 100644
index 0000000..e31308f
--- /dev/null
+++ b/app/build-sitemap.ts
@@ -0,0 +1,3 @@
+import { sitemap } from "./services/Sitemap";
+
+sitemap.generateSitemap();
diff --git a/app/buildPublicUrl.ts b/app/buildPublicUrl.ts
index 07a4da9..ac28e0f 100644
--- a/app/buildPublicUrl.ts
+++ b/app/buildPublicUrl.ts
@@ -4,7 +4,9 @@ export function buildPublicUrl(pageContext: PageContext, resource: string) {
const { baseUrl } = pageContext;
const url = new URL(
resource,
- process.env.NODE_ENV === "production" ? "https://memento-dev.fr" : baseUrl,
+ process.env.NODE_ENV === "production"
+ ? "https://memento-dev.fr"
+ : baseUrl || "http://localhost:5500",
).toString();
return url;
diff --git a/app/components/QuickLinks.tsx b/app/components/QuickLinks.tsx
index b2b46e0..b36fc91 100644
--- a/app/components/QuickLinks.tsx
+++ b/app/components/QuickLinks.tsx
@@ -3,14 +3,21 @@ import type { IconProps } from "./Icon";
import { Icon } from "./Icon";
import { Link } from "./Link";
+import clsx from "clsx";
type QuickLinksProps = {
children: JSXElement;
+ class?: string;
};
export default function QuickLinks(props: QuickLinksProps) {
return (
-
+
{props.children}
);
@@ -20,6 +27,7 @@ type QuickLinkProps = {
title: string;
description: string;
href: string;
+ lastEdited?: Date;
icon: IconProps["icon"];
};
@@ -29,13 +37,26 @@ QuickLinks.QuickLink = (props: QuickLinkProps) => (
-
+
{props.title}
+ {props.lastEdited && (
+
+ Dernière modification :{" "}
+
+
+ )}
+
{props.description}
diff --git a/app/layouts/DocsLayout.tsx b/app/layouts/DocsLayout.tsx
index 1eee27d..c8e44b0 100644
--- a/app/layouts/DocsLayout.tsx
+++ b/app/layouts/DocsLayout.tsx
@@ -2,6 +2,7 @@ import type { JSXElement } from "solid-js";
import { PrevNextLinks } from "@/components/PrevNextLinks";
import { usePageContext } from "vike-solid/usePageContext";
+import { LatestDocs } from "@/partials/LatestDocs";
import { clientOnly } from "vike-solid/clientOnly";
import { clock } from "solid-heroicons/outline";
import { navigation } from "@/libs/navigation";
@@ -21,21 +22,25 @@ export function DocsLayout(props: DocsLayoutProps) {
return (
<>
-
-
-
- {props.children}
-
-
-
+
+
+
+
+ {props.children}
+
+
+
-
+
+
+
+
>
);
}
diff --git a/app/layouts/LayoutDefault.tsx b/app/layouts/LayoutDefault.tsx
index 7dbd8d4..19c6858 100755
--- a/app/layouts/LayoutDefault.tsx
+++ b/app/layouts/LayoutDefault.tsx
@@ -3,8 +3,8 @@ import type { JSXElement } from "solid-js";
import { usePageContext } from "vike-solid/usePageContext";
import { SmoothScroll } from "@/components/SmoothScroll";
import { HeroSection } from "@/partials/HeroSection";
-import { Navigation } from "@/partials/Navigation";
import { clientOnly } from "vike-solid/clientOnly";
+import { Navigation } from "@/partials/Navigation";
import { Header } from "@/partials/Header";
import { Footer } from "@/partials/Footer";
import { DocsLayout } from "./DocsLayout";
@@ -40,7 +40,10 @@ export default function DefaultLayout(props: DefaultLayoutProps) {
- {props.children}
+
+
+ {props.children}
+
diff --git a/app/package.json b/app/package.json
index 7033f39..04b058e 100755
--- a/app/package.json
+++ b/app/package.json
@@ -1,7 +1,9 @@
{
"scripts": {
"dev": "bun ./fastify-entry.ts",
- "build": "cross-env DEBUG=vike:error,vike:log vike build",
+ "build": "bun run build:sitemap && bun run build:server",
+ "build:server": "cross-env DEBUG=vike:error,vike:log vike build",
+ "build:sitemap": "cross-env NODE_ENV=production bun ./build-sitemap.ts",
"preview": "cross-env NODE_ENV=production bun ./fastify-server.ts",
"production": "bun run build && bun run preview",
"lint": "biome lint --write .",
diff --git a/app/pages/+Head.tsx b/app/pages/+Head.tsx
index 83ff130..5ceca3e 100755
--- a/app/pages/+Head.tsx
+++ b/app/pages/+Head.tsx
@@ -1,13 +1,23 @@
+import { usePageContext } from "vike-solid/usePageContext";
import blurCyanImage from "@/images/blur-cyan.webp";
+import { buildPublicUrl } from "@/buildPublicUrl";
import logoUrl from "@/assets/logo.svg";
// https://vike.dev/Head
export default function HeadDefault() {
+ const pageContext = usePageContext();
+
+ const getCanonicalUrl = () => {
+ return buildPublicUrl(pageContext, pageContext.urlParsed.pathname);
+ };
+
return (
<>
+
+
+
+
+
{
+export const onPageTransitionEnd: OnPageTransitionEndAsync = async (
+ pageContext,
+) => {
NProgress.done();
NProgress.remove();
+
+ updateCanonicalsTag(pageContext);
+};
+
+const updateCanonicalsTag = (pageContext: PageContext) => {
+ const canonicalNativeTag = findOrCreateTag("link", {
+ rel: "canonical",
+ });
+ const canonicalOGTag = findOrCreateTag("meta", {
+ property: "og:url",
+ });
+ const typeOGTag = findOrCreateTag("meta", {
+ property: "og:type",
+ });
+ const localOGTag = findOrCreateTag("meta", {
+ property: "og:locale",
+ });
+ const siteNameOGTag = findOrCreateTag("meta", {
+ property: "og:site_name",
+ });
+
+ const canonicalUrl = buildPublicUrl(
+ pageContext,
+ pageContext.urlParsed.pathname,
+ );
+
+ canonicalNativeTag.setAttribute("href", canonicalUrl);
+ canonicalOGTag.setAttribute("content", canonicalUrl);
+ typeOGTag.setAttribute("content", "website");
+ localOGTag.setAttribute("content", "fr-FR");
+ siteNameOGTag.setAttribute("content", document.title);
+};
+
+const findOrCreateTag = (
+ tagName: string,
+ attributes: Record,
+): T => {
+ const head = document.head;
+ let tag: HTMLElement | null = head.querySelector(tagName);
+ if (tag) return tag as T;
+
+ tag = document.createElement(tagName);
+ for (const [key, value] of Object.entries(attributes)) {
+ tag.setAttribute(key, value);
+ }
+
+ document.head.appendChild(tag);
+
+ return tag as T;
};
diff --git a/app/partials/LatestDocs.tsx b/app/partials/LatestDocs.tsx
new file mode 100644
index 0000000..f1ae701
--- /dev/null
+++ b/app/partials/LatestDocs.tsx
@@ -0,0 +1,31 @@
+import type { Data } from "@/pages/+data";
+
+import QuickLinks from "@/components/QuickLinks";
+import { useData } from "vike-solid/useData";
+import { For } from "solid-js";
+
+export function LatestDocs() {
+ const data = useData();
+
+ return (
+
+
+ Dernières documentations
+
+
+
+
+ {(doc) => (
+
+ )}
+
+
+
+ );
+}
diff --git a/app/partials/Navigation.tsx b/app/partials/Navigation.tsx
index 0232d1a..2d8d2da 100644
--- a/app/partials/Navigation.tsx
+++ b/app/partials/Navigation.tsx
@@ -27,7 +27,9 @@ function NavigationItem(props: NavigationItemProps) {
return props.section.links.some((link) => checkIsLinkActive(link));
};
- const [isOpened, setIsOpened] = createSignal(checkIsActive());
+ const [isOpened, setIsOpened] = createSignal(
+ checkIsActive() || pageContext.urlPathname === "/",
+ );
createEffect(() => {
// If the current URL path is the same as the link's href, set isOpened to true
diff --git a/app/public/robots.txt b/app/public/robots.txt
new file mode 100644
index 0000000..ac03453
--- /dev/null
+++ b/app/public/robots.txt
@@ -0,0 +1,3 @@
+User-agent: *
+Disallow:
+Sitemap: https://memento-dev.fr/sitemap.xml
\ No newline at end of file
diff --git a/app/services/DocCache.ts b/app/services/DocCache.ts
index e1c71e5..6153a5f 100644
--- a/app/services/DocCache.ts
+++ b/app/services/DocCache.ts
@@ -1,5 +1,3 @@
-import type { FlexSearchData } from "./FlexSearchService";
-import type { TableOfContents } from "@/remarkHeadingId";
import type { Node } from "@markdoc/markdoc";
import { slugifyWithCounter } from "@sindresorhus/slugify";
@@ -16,6 +14,8 @@ export type SectionCache = {
frontmatter?: SectionFrontmatter;
markdocNode: Node;
sections: DocSection[];
+ lastEdit: Date;
+ filePath: string;
};
export type DocSection = {
@@ -32,6 +32,14 @@ type SectionFrontmatter = {
tags: string[];
};
+type OrderConfig = {
+ limit: number;
+ includedBasePaths: string[];
+ excludedBasePaths: string[];
+ includedFileNames: string[];
+ excludedFileNames: string[];
+};
+
class DocCache {
private static readonly pagesDir = path.join(__dirname, "pages");
private static instance: DocCache | null = null;
@@ -162,12 +170,17 @@ class DocCache {
.replace(/\+Page\.md(x)?$/, "")
.replace(/\/+/, "/")
.replace(/\/$/, "");
+ const lastEdit = new Date(
+ (await fs.stat(path.join(DocCache.pagesDir, file))).mtime,
+ );
this.cache.set(filePath, {
content,
frontmatter,
markdocNode,
sections: this.getTableOfContents(markdocNode),
+ lastEdit,
+ filePath,
});
}
@@ -207,6 +220,84 @@ class DocCache {
return this.cache.get(file);
}
+ public orderByLastEdit(customConfig?: Partial): SectionCache[] {
+ const config: OrderConfig = {
+ excludedBasePaths: [],
+ includedBasePaths: [],
+ excludedFileNames: [],
+ includedFileNames: [],
+ limit: 0,
+ ...customConfig,
+ };
+
+ const checkIfIncludedBasePath = (doc: SectionCache) => {
+ if (config.includedBasePaths.length > 0) {
+ return config.includedBasePaths.some((basePath) => {
+ return doc.filePath.startsWith(basePath);
+ });
+ }
+
+ return true;
+ };
+
+ const checkIfExcludedBasePaths = (doc: SectionCache) => {
+ if (config.excludedBasePaths.length > 0) {
+ return !config.excludedBasePaths.some((basePath) => {
+ return doc.filePath.startsWith(basePath);
+ });
+ }
+
+ return true;
+ };
+
+ const checkIfIncludedFileNames = (doc: SectionCache) => {
+ if (config.includedFileNames.length > 0) {
+ return config.includedFileNames.some((fileName) => {
+ return doc.filePath.includes(fileName);
+ });
+ }
+
+ return true;
+ };
+
+ const checkIfExcludedFileNames = (doc: SectionCache) => {
+ if (config.excludedFileNames.length > 0) {
+ return !config.excludedFileNames.some((fileName) => {
+ return doc.filePath === fileName;
+ });
+ }
+
+ return true;
+ };
+
+ const sortedDocs = Array.from(this.cache.values())
+ .sort((a, b) => b.lastEdit.getTime() - a.lastEdit.getTime())
+ .filter((doc) => {
+ const isIncluded = [
+ checkIfIncludedBasePath(doc),
+ checkIfExcludedBasePaths(doc),
+ checkIfIncludedFileNames(doc),
+ checkIfExcludedFileNames(doc),
+ ].every((check) => check === true);
+
+ // DEBUG
+ // if (!isIncluded) {
+ // console.group(doc.filePath);
+ // console.log("includedBasePaths", checkIfIncludedBasePath(doc));
+ // console.log("excludedBasePaths", checkIfExcludedBasePaths(doc));
+ // console.log("includedFileNames", checkIfIncludedFileNames(doc));
+ // console.log("excludedFileNames", checkIfExcludedFileNames(doc));
+ // console.groupEnd();
+ // }
+
+ return isIncluded;
+ });
+
+ if (config.limit > 0) return sortedDocs.slice(0, config.limit);
+
+ return sortedDocs;
+ }
+
public waitingForCache(timeout: 20000): Promise {
const timer = hrtime();
diff --git a/app/services/Sitemap.ts b/app/services/Sitemap.ts
new file mode 100644
index 0000000..4c39db1
--- /dev/null
+++ b/app/services/Sitemap.ts
@@ -0,0 +1,167 @@
+import { navigation } from "@/libs/navigation";
+import path from "node:path";
+import fs from "node:fs";
+
+const __dirname = path.resolve();
+
+const getBaseUrl = () => {
+ if (process.env.NODE_ENV === "production") {
+ return "https://memento-dev.fr";
+ }
+
+ return `http://localhost:${process.env.PORT || 3000}`;
+};
+
+type SitemapElement = {
+ location: string;
+ lastmod: string;
+ priority: string;
+};
+
+class Sitemap {
+ private readonly pagesPath = path.join(__dirname, "pages");
+ private readonly sitemapPath = path.join(__dirname, "public", "sitemap.xml");
+ private readonly lastModified = new Date().toISOString();
+ private readonly baseUrl = getBaseUrl();
+
+ private urls: SitemapElement[] = [];
+ private sitemap = "";
+
+ private static instance: Sitemap;
+
+ private constructor() {}
+
+ public static getInstance(): Sitemap {
+ if (!Sitemap.instance) {
+ Sitemap.instance = new Sitemap();
+ }
+ return Sitemap.instance;
+ }
+
+ private resetMemory(): void {
+ this.sitemap = "";
+ this.urls = [];
+ }
+
+ private prependSitemap(): void {
+ this.sitemap = ``;
+ this.sitemap += ``;
+ }
+
+ private appendSitemap(): void {
+ this.sitemap += "";
+ }
+
+ private addSitemapElement(url: SitemapElement): void {
+ this.sitemap += "";
+ this.sitemap += `${url.location}`;
+ this.sitemap += `${url.lastmod || this.lastModified}`;
+ this.sitemap += `${url.priority}`;
+ this.sitemap += "";
+ }
+
+ private buildSitemap(): void {
+ this.prependSitemap();
+ this.urls.forEach(this.addSitemapElement.bind(this));
+ this.appendSitemap();
+ }
+
+ private saveSitemap(): void {
+ fs.writeFileSync(this.sitemapPath, this.sitemap, "utf8");
+ }
+
+ private loadPriority(href: string): string {
+ const isRootUrl = ["/", ""].includes(href);
+
+ if (isRootUrl) return "1.0";
+ const countOfSlashes = (href.match(/\//g) || []).length;
+ return (1 - countOfSlashes * 0.1).toFixed(1);
+ }
+
+ private loadLastModified(stat?: fs.Stats): string {
+ return stat ? stat.mtime.toISOString() : this.lastModified;
+ }
+
+ private getFileServerLocation(href: string) {
+ if (href === "/") return path.join(this.pagesPath, "index", "+Page.mdx");
+ return path.join(this.pagesPath, href.replace("/", ""), "+Page.mdx");
+ }
+
+ private loadSubitems(
+ subitems: (typeof navigation)[number]["links"][number]["subitems"],
+ ): void {
+ for (const subitem of subitems) {
+ const fileLocation = this.getFileServerLocation(subitem.href);
+ let fileDetails: fs.Stats | undefined;
+ try {
+ fileDetails = fs.statSync(fileLocation);
+ } catch (error) {
+ console.error(`Error loading file for ${subitem.href}:`, error);
+ }
+
+ const priority = this.loadPriority(subitem.href);
+ const lastmod = this.loadLastModified(fileDetails);
+ const location = `${this.baseUrl}${subitem.href}`;
+
+ this.urls.push({
+ location,
+ lastmod,
+ priority,
+ });
+ }
+ }
+
+ private loadSection(section: (typeof navigation)[number]): void {
+ for (const link of section.links) {
+ if (link.subitems.length > 0) {
+ this.loadSubitems(link.subitems);
+ return;
+ }
+
+ const fileLocation = this.getFileServerLocation(link.href);
+ let fileDetails: fs.Stats | undefined;
+
+ try {
+ fileDetails = fs.statSync(fileLocation);
+ } catch (error) {
+ console.error(`Error loading file for ${link.href}:`, error);
+ }
+
+ const priority = this.loadPriority(link.href);
+ const lastmod = this.loadLastModified(fileDetails);
+ const location = `${this.baseUrl}${link.href}`;
+
+ this.urls.push({
+ location,
+ lastmod,
+ priority,
+ });
+ }
+ }
+
+ private loadUrls(): void {
+ navigation.forEach(this.loadSection.bind(this));
+
+ this.urls = Array.from(new Set(this.urls)).sort((a, b) => {
+ return a.location.localeCompare(b.location);
+ });
+ }
+
+ public getSitemap(): string {
+ if (!this.sitemap) this.generateSitemap();
+ return this.sitemap;
+ }
+
+ public generateSitemap(): void {
+ console.log("Generating sitemap...");
+
+ this.resetMemory();
+ this.loadUrls();
+ this.buildSitemap();
+ this.saveSitemap();
+
+ console.log("Sitemap generated successfully.");
+ }
+}
+
+export const sitemap = Sitemap.getInstance();