From 123cc316db33f0d478b9fffe16c104277711e6fe Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 23 Feb 2026 11:04:57 +0100 Subject: [PATCH 1/9] wip --- .github/workflows/deploy.yaml | 3 + README.md | 92 ++-- biome.json | 40 ++ .../docs/documentation/foundamentals/blog.mdx | 16 +- .../foundamentals/components/card.mdx | 10 +- .../foundamentals/components/code-block.mdx | 5 +- package.json | 38 +- pnpm-lock.yaml | 461 ++++++------------ src/assets/css/global.css | 18 +- src/content.config.ts | 2 + src/hooks/build-doc.ts | 108 +++- .../content/card-group/card-group.astro | 2 +- .../content/code-group/code-group.tsx | 6 +- src/lib/components/content/codeblock.astro | 2 +- .../elements/DocNavigationWrapper.astro | 8 +- src/lib/components/elements/articles.astro | 24 +- src/lib/components/elements/collapsible.tsx | 50 +- src/lib/components/elements/doc-switcher.tsx | 70 ++- src/lib/components/elements/navbar.tsx | 79 +-- .../components/elements/search-command.tsx | 97 ++-- src/lib/components/elements/sidebar.tsx | 60 +++ src/lib/layouts/BaseLayout.astro | 57 ++- src/lib/layouts/DocsLayout.astro | 152 ++---- src/lib/plugins/parser/plugin.ts | 9 +- .../plugins/shiki/transformer-meta-label.ts | 8 +- src/lib/utils.ts | 155 ++++-- src/pages/404.astro | 30 ++ src/pages/blog/[slug].astro | 31 +- src/pages/docs/[...slug].astro | 77 ++- src/pages/rss.xml.ts | 30 ++ 30 files changed, 977 insertions(+), 763 deletions(-) create mode 100644 biome.json create mode 100644 src/lib/components/elements/sidebar.tsx create mode 100644 src/pages/404.astro create mode 100644 src/pages/rss.xml.ts diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 4deca22..61259ce 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -31,6 +31,9 @@ jobs: - name: πŸ“¦ Install dependencies run: pnpm install + - name: πŸ” Lint + run: pnpm run lint + - name: 🎭 Install Playwright browsers run: pnpm exec playwright install --with-deps diff --git a/README.md b/README.md index ff19a3e..c05d9db 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,72 @@ -# Astro Starter Kit: Basics +# Explainer -```sh -npm create astro@latest -- --template basics -``` +A documentation framework built on Astro 5 and React 19. Write your docs in MDX, get a fast static site with built-in search, blog, SEO, and dark mode. -[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics) -[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics) -[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json) +## Features -> πŸ§‘β€πŸš€ **Seasoned astronaut?** Delete this file. Have fun! +| Category | Feature | Description | +| ------------- | --------------------- | --------------------------------------------------------- | +| Documentation | Multi-section docs | Nested sidebar with collapsible sections and doc switcher | +| Documentation | Custom MDX directives | `::component{attr="value"}` syntax for rich content | +| Documentation | 8+ MDX components | Callout, Card, Code Group, Code Preview, Step, and more | +| Documentation | Mermaid diagrams | Rendered at build time via rehype-mermaid | +| Blog | Full blog system | Tags, drafts, author profiles, publication dates | +| Blog | RSS feed | Auto-generated at `/rss.xml` | +| Navigation | Integrated search | Command palette (Cmd+K) with fuzzy filtering | +| Navigation | Instant transitions | Astro View Transitions with persistent sidebar | +| Navigation | Breadcrumbs | Auto-generated page hierarchy | +| Code | Syntax highlighting | Shiki dual-theme (light/dark) with 60+ language icons | +| Code | Code transformers | Diff, line highlight, focus, word highlight, error levels | +| Theming | Dark mode | Light, dark, and system preference with localStorage | +| Theming | Tailwind CSS v4 | OKLCH color system with shadcn/ui components | +| SEO | Meta tags | Open Graph, Twitter Cards, canonical URLs | +| SEO | Sitemap | Auto-generated via @astrojs/sitemap | +| SEO | OG images | Auto-generated at build time (Satori + Resvg) | +| Accessibility | Standards | Skip link, prefers-reduced-motion, keyboard navigation | +| Accessibility | Custom 404 | Branded error page with navigation links | +| DX | Linting & formatting | Biome with strict TypeScript | +| DX | CI/CD | GitHub Actions β€” lint, build, deploy to Cloudflare Pages | +| DX | Docker | Multi-stage build with Nginx | -![just-the-basics](https://github.com/withastro/astro/assets/2244813/a0a5533c-a856-4198-8470-2d67b1d7c554) +## Tech Stack -## πŸš€ Project Structure +Astro 5 / React 19 / MDX / TypeScript / Tailwind CSS v4 / shadcn/ui + Radix UI / Shiki / Biome / Cloudflare Pages -Inside of your Astro project, you'll see the following folders and files: +## Getting Started -```text -/ -β”œβ”€β”€ public/ -β”‚ └── favicon.svg -β”œβ”€β”€ src/ -β”‚ β”œβ”€β”€ layouts/ -β”‚ β”‚ └── Layout.astro -β”‚ └── pages/ -β”‚ └── index.astro -└── package.json +```sh +pnpm install +pnpm dev ``` -To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/). +Open [http://localhost:4321](http://localhost:4321). + +| Command | Action | +| --------------- | -------------------------------- | +| `pnpm dev` | Start dev server | +| `pnpm build` | Build for production (`./dist/`) | +| `pnpm preview` | Preview production build | +| `pnpm lint` | Check code with Biome | +| `pnpm lint:fix` | Fix linting issues | +| `pnpm format` | Format code with Biome | + +## Configuration + +All settings are centralized in `explainer.config.ts` via `defineExplainerConfig()`: + +- **Project** β€” name, repository URL +- **SEO** β€” title, description, default thumbnail +- **Socials** β€” GitHub, Twitter, LinkedIn links +- **Blog** β€” default thumbnail, author profiles +- **Navbar** β€” custom navigation links +- **Content** β€” MDX component mappings, 60+ language icon mappings -## 🧞 Commands +## Deployment -All commands are run from the root of the project, from a terminal: +**Cloudflare Pages** β€” Automatic via GitHub Actions on push to `main` (lint + build + deploy). -| Command | Action | -| :------------------------ | :----------------------------------------------- | -| `npm install` | Installs dependencies | -| `npm run dev` | Starts local dev server at `localhost:4321` | -| `npm run build` | Build your production site to `./dist/` | -| `npm run preview` | Preview your build locally, before deploying | -| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | -| `npm run astro -- --help` | Get help using the Astro CLI | +**Docker** β€” `docker build -t explainer . && docker run -p 8080:8080 explainer` (Node 20 Alpine + Nginx 1.28 Alpine). -## πŸ‘€ Want to learn more? +## License -Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). +See [LICENSE](LICENSE) for details. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..57e3b6e --- /dev/null +++ b/biome.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignore": ["dist", "node_modules", ".astro", "*.astro"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedImports": "warn", + "noUnusedVariables": "warn" + }, + "style": { + "noNonNullAssertion": "off" + }, + "suspicious": { + "noExplicitAny": "warn" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all" + } + } +} diff --git a/content/docs/documentation/foundamentals/blog.mdx b/content/docs/documentation/foundamentals/blog.mdx index 07c6067..8121d5e 100644 --- a/content/docs/documentation/foundamentals/blog.mdx +++ b/content/docs/documentation/foundamentals/blog.mdx @@ -19,12 +19,13 @@ To create a new article, you need to create a new markdown file in the `content/ You should use the frontmatter on top of the file to define the article metadata. -> [!WARNING] -> The `permalink` property is required. If you modify it, the URL of the article will change. +:::callout{variant="warning"} +The `permalink` property is required. If you modify it, the URL of the article will change. +::: -:::codegroup labels=[frontmatter, schema] +:::codegroup -```mdx +```mdx [frontmatter] --- title: "Article title" description: "Article description" @@ -37,7 +38,7 @@ publishedAt: 2024-01-01:23:00:00 --- ``` -```ts +```ts [schema] const schema = z.object({ title: z.string(), description: z.string(), @@ -55,8 +56,9 @@ const schema = z.object({ The article will be visible on the blog page if the `publishedAt` date is defined and is in the future. -> [!NOTE] -> You can also remove or comment the `publishedAt` date to unpublish the article. +:::callout{variant="warning"} +You can also remove or comment the `publishedAt` date to unpublish the article. +::: In the blog index page, the articles are sorted by `publishedAt` date and displayed in descending order. diff --git a/content/docs/documentation/foundamentals/components/card.mdx b/content/docs/documentation/foundamentals/components/card.mdx index 9ec2631..36d0583 100644 --- a/content/docs/documentation/foundamentals/components/card.mdx +++ b/content/docs/documentation/foundamentals/components/card.mdx @@ -51,8 +51,9 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i You can customize the number of elements per line by setting the `cols` prop. -> [!note] -> Default card per line was fixed to 2 +:::callout{variant="info"} +Default card per line was fixed to 2 +::: ::::::preview @@ -143,8 +144,9 @@ Vous pouvez personaliser chacune de vos cartes en utilisant les props suivantes - `label`: Le texte Γ  afficher en haut de la carte. (required) - `icon`: L'icΓ΄ne Γ  afficher en haut de la carte. -> [!note] -> The cards use the [`iconify`](https://icon-sets.iconify.design/) library to display icons. +:::callout{variant="info"} +The cards use the [`iconify`](https://icon-sets.iconify.design/) library to display icons. +::: ::::::preview diff --git a/content/docs/documentation/foundamentals/components/code-block.mdx b/content/docs/documentation/foundamentals/components/code-block.mdx index 7c083df..f8a4562 100644 --- a/content/docs/documentation/foundamentals/components/code-block.mdx +++ b/content/docs/documentation/foundamentals/components/code-block.mdx @@ -261,8 +261,9 @@ impl User { ## Group code blocks -> [!warning] This is a **collapsible** callout -> Only code blocks can be used in a group. +:::callout{variant="warning"} +This is a **collapsible** callout. Only code blocks can be used in a group. +::: :::::preview ::::code-preview diff --git a/package.json b/package.json index 80a65a7..78c1ec1 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,16 @@ "dev": "astro dev", "build": "astro build", "preview": "astro preview", - "astro": "astro" + "astro": "astro", + "lint": "biome check src/", + "lint:fix": "biome check --write src/", + "format": "biome format --write src/" }, "dependencies": { "@astrojs/mdx": "^4.3.12", "@astrojs/react": "^4.4.2", + "@astrojs/rss": "^4.0.11", "@astrojs/sitemap": "^3.6.0", - "@expressive-code/plugin-line-numbers": "^0.40.2", "@iconify-json/devicon": "^1.2.23", "@iconify/react": "^6.0.0", "@lucide/astro": "^0.488.0", @@ -24,39 +27,20 @@ "@resvg/resvg-js": "^2.6.2", "@scalar/api-reference-react": "^0.6.19", "@tailwindcss/vite": "^4.0.17", - "@types/culori": "^4.0.1", - "@types/hast": "^3.0.4", - "@types/luxon": "^3.6.2", - "@types/mdast": "^4.0.4", - "@types/node": "^24.10.0", - "@types/react": "^19.0.12", - "@types/react-dom": "^19.0.4", "astro": "^5.16.3", "astro-icon": "^1.1.5", - "autoprefixer": "^10.4.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "culori": "^4.0.2", "gray-matter": "^4.0.3", - "hast": "^1.0.0", - "hast-util-to-html": "^9.0.5", - "hastscript": "^9.0.1", "lucide-react": "^0.485.0", "luxon": "^3.6.1", - "mdast": "^3.0.0", - "mermaid": "^11.6.0", - "motion": "^12.6.3", "playwright": "^1.51.1", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-tweet": "^3.2.2", - "rehype-callouts": "^2.0.2", - "rehype-code-group": "^0.2.4", "rehype-mermaid": "^3.0.0", "remark-directive": "^4.0.0", - "remark-toc": "^9.0.0", - "roboto": "link:@types/@fontsource/roboto", "satori": "^0.18.3", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.17", @@ -65,6 +49,14 @@ "unist-util-visit": "^5.0.0" }, "devDependencies": { - "@shikijs/transformers": "^3.2.1" + "@biomejs/biome": "^1.9.4", + "@shikijs/transformers": "^3.2.1", + "@types/culori": "^4.0.1", + "@types/hast": "^3.0.4", + "@types/luxon": "^3.6.2", + "@types/mdast": "^4.0.4", + "@types/node": "^24.10.0", + "@types/react": "^19.0.12", + "@types/react-dom": "^19.0.4" } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33b7ae1..96bdaed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,12 +11,12 @@ dependencies: '@astrojs/react': specifier: ^4.4.2 version: 4.4.2(@types/node@24.10.0)(@types/react-dom@19.1.6)(@types/react@19.1.8)(react-dom@19.1.0)(react@19.1.0) + '@astrojs/rss': + specifier: ^4.0.11 + version: 4.0.15 '@astrojs/sitemap': specifier: ^3.6.0 version: 3.6.0 - '@expressive-code/plugin-line-numbers': - specifier: ^0.40.2 - version: 0.40.2 '@iconify-json/devicon': specifier: ^1.2.23 version: 1.2.29 @@ -50,36 +50,12 @@ dependencies: '@tailwindcss/vite': specifier: ^4.0.17 version: 4.1.11(vite@7.0.0) - '@types/culori': - specifier: ^4.0.1 - version: 4.0.1 - '@types/hast': - specifier: ^3.0.4 - version: 3.0.4 - '@types/luxon': - specifier: ^3.6.2 - version: 3.6.2 - '@types/mdast': - specifier: ^4.0.4 - version: 4.0.4 - '@types/node': - specifier: ^24.10.0 - version: 24.10.0 - '@types/react': - specifier: ^19.0.12 - version: 19.1.8 - '@types/react-dom': - specifier: ^19.0.4 - version: 19.1.6(@types/react@19.1.8) astro: specifier: ^5.16.3 version: 5.16.3(@types/node@24.10.0)(typescript@5.8.3) astro-icon: specifier: ^1.1.5 version: 1.1.5 - autoprefixer: - specifier: ^10.4.21 - version: 10.4.21(postcss@8.5.6) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -95,30 +71,12 @@ dependencies: gray-matter: specifier: ^4.0.3 version: 4.0.3 - hast: - specifier: ^1.0.0 - version: 1.0.0 - hast-util-to-html: - specifier: ^9.0.5 - version: 9.0.5 - hastscript: - specifier: ^9.0.1 - version: 9.0.1 lucide-react: specifier: ^0.485.0 version: 0.485.0(react@19.1.0) luxon: specifier: ^3.6.1 version: 3.6.1 - mdast: - specifier: ^3.0.0 - version: 3.0.0 - mermaid: - specifier: ^11.6.0 - version: 11.7.0 - motion: - specifier: ^12.6.3 - version: 12.23.0(react-dom@19.1.0)(react@19.1.0) playwright: specifier: ^1.51.1 version: 1.53.2 @@ -128,27 +86,12 @@ dependencies: react-dom: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) - react-tweet: - specifier: ^3.2.2 - version: 3.2.2(react-dom@19.1.0)(react@19.1.0) - rehype-callouts: - specifier: ^2.0.2 - version: 2.1.1 - rehype-code-group: - specifier: ^0.2.4 - version: 0.2.4(typescript@5.8.3) rehype-mermaid: specifier: ^3.0.0 version: 3.0.0(playwright@1.53.2) remark-directive: specifier: ^4.0.0 version: 4.0.0 - remark-toc: - specifier: ^9.0.0 - version: 9.0.0 - roboto: - specifier: link:@types/@fontsource/roboto - version: link:@types/@fontsource/roboto satori: specifier: ^0.18.3 version: 0.18.3 @@ -169,9 +112,33 @@ dependencies: version: 5.0.0 devDependencies: + '@biomejs/biome': + specifier: ^1.9.4 + version: 1.9.4 '@shikijs/transformers': specifier: ^3.2.1 version: 3.7.0 + '@types/culori': + specifier: ^4.0.1 + version: 4.0.1 + '@types/hast': + specifier: ^3.0.4 + version: 3.0.4 + '@types/luxon': + specifier: ^3.6.2 + version: 3.6.2 + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 + '@types/node': + specifier: ^24.10.0 + version: 24.10.0 + '@types/react': + specifier: ^19.0.12 + version: 19.1.8 + '@types/react-dom': + specifier: ^19.0.4 + version: 19.1.6(@types/react@19.1.8) packages: @@ -292,6 +259,13 @@ packages: - yaml dev: false + /@astrojs/rss@4.0.15: + resolution: {integrity: sha512-uXO/k6AhRkIDXmRoc6xQpoPZrimQNUmS43X4+60yunfuMNHtSRN5e/FiSi7NApcZqmugSMc5+cJi8ovqgO+qIg==} + dependencies: + fast-xml-parser: 5.3.7 + piccolore: 0.1.3 + dev: false + /@astrojs/sitemap@3.6.0: resolution: {integrity: sha512-4aHkvcOZBWJigRmMIAJwRQXBS+ayoP5z40OklTXYXhUDhwusz+DyDl+nSshY6y9DvkVEavwNcFO8FD81iGhXjg==} dependencies: @@ -512,6 +486,94 @@ packages: '@babel/helper-validator-identifier': 7.28.5 dev: false + /@biomejs/biome@1.9.4: + resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} + engines: {node: '>=14.21.3'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@biomejs/cli-darwin-arm64': 1.9.4 + '@biomejs/cli-darwin-x64': 1.9.4 + '@biomejs/cli-linux-arm64': 1.9.4 + '@biomejs/cli-linux-arm64-musl': 1.9.4 + '@biomejs/cli-linux-x64': 1.9.4 + '@biomejs/cli-linux-x64-musl': 1.9.4 + '@biomejs/cli-win32-arm64': 1.9.4 + '@biomejs/cli-win32-x64': 1.9.4 + dev: true + + /@biomejs/cli-darwin-arm64@1.9.4: + resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@biomejs/cli-darwin-x64@1.9.4: + resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@biomejs/cli-linux-arm64-musl@1.9.4: + resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@biomejs/cli-linux-arm64@1.9.4: + resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@biomejs/cli-linux-x64-musl@1.9.4: + resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@biomejs/cli-linux-x64@1.9.4: + resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@biomejs/cli-win32-arm64@1.9.4: + resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@biomejs/cli-win32-x64@1.9.4: + resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@braintree/sanitize-url@7.1.1: resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} dev: false @@ -676,11 +738,6 @@ packages: w3c-keyname: 2.2.8 dev: false - /@ctrl/tinycolor@4.1.0: - resolution: {integrity: sha512-WyOx8cJQ+FQus4Mm4uPIZA64gbk3Wxh0so5Lcii0aJifqwoVOlfFtorjLE0Hen4OYyHZMXDWqMmaQemBhgxFRQ==} - engines: {node: '>=14'} - dev: false - /@emnapi/runtime@1.7.1: resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} requiresBuild: true @@ -914,26 +971,6 @@ packages: dev: false optional: true - /@expressive-code/core@0.40.2: - resolution: {integrity: sha512-gXY3v7jbgz6nWKvRpoDxK4AHUPkZRuJsM79vHX/5uhV9/qX6Qnctp/U/dMHog/LCVXcuOps+5nRmf1uxQVPb3w==} - dependencies: - '@ctrl/tinycolor': 4.1.0 - hast-util-select: 6.0.4 - hast-util-to-html: 9.0.5 - hast-util-to-text: 4.0.2 - hastscript: 9.0.1 - postcss: 8.5.6 - postcss-nested: 6.2.0(postcss@8.5.6) - unist-util-visit: 5.0.0 - unist-util-visit-parents: 6.0.1 - dev: false - - /@expressive-code/plugin-line-numbers@0.40.2: - resolution: {integrity: sha512-YMLkn68n9a9DI/4fQW/f6QJ33uQUzHmGdV3pDl+f6fVTxv7rvhRja+UtPksm0ZJpft6vrrACV8wS2TaH77SBzw==} - dependencies: - '@expressive-code/core': 0.40.2 - dev: false - /@floating-ui/core@1.7.2: resolution: {integrity: sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==} dependencies: @@ -3058,7 +3095,7 @@ packages: /@types/culori@4.0.1: resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==} - dev: false + dev: true /@types/d3-array@3.2.1: resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} @@ -3272,7 +3309,7 @@ packages: /@types/luxon@3.6.2: resolution: {integrity: sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==} - dev: false + dev: true /@types/mdast@4.0.4: resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -3307,7 +3344,6 @@ packages: resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==} dependencies: undici-types: 7.16.0 - dev: false /@types/react-dom@19.1.6(@types/react@19.1.8): resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==} @@ -3315,13 +3351,11 @@ packages: '@types/react': ^19.0.0 dependencies: '@types/react': 19.1.8 - dev: false /@types/react@19.1.8: resolution: {integrity: sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==} dependencies: csstype: 3.1.3 - dev: false /@types/sax@1.2.7: resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} @@ -3342,10 +3376,6 @@ packages: dev: false optional: true - /@types/ungap__structured-clone@1.2.0: - resolution: {integrity: sha512-ZoaihZNLeZSxESbk9PUAPZOlSpcKx81I1+4emtULDVmBLkYutTcMlCj2K9VNlf9EWODxdO6gkAqEaLorXwZQVA==} - dev: false - /@types/unist@2.0.11: resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} dev: false @@ -3827,22 +3857,6 @@ packages: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} dev: false - /autoprefixer@10.4.21(postcss@8.5.6): - resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - dependencies: - browserslist: 4.25.1 - caniuse-lite: 1.0.30001726 - fraction.js: 4.3.7 - normalize-range: 0.1.2 - picocolors: 1.1.1 - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - dev: false - /axios@1.10.0: resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==} dependencies: @@ -3875,10 +3889,6 @@ packages: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} dev: false - /bcp-47-match@2.0.3: - resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} - dev: false - /boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} dev: false @@ -4195,10 +4205,6 @@ packages: nth-check: 2.1.1 dev: false - /css-selector-parser@3.1.3: - resolution: {integrity: sha512-gJMigczVZqYAk0hPVzx/M4Hm1D9QOtqkdQk9005TNzDIUGzo5cnHEDiKUT7jGPximL/oYb+LIitcHFQ4aKupxg==} - dev: false - /css-to-react-native@3.2.0: resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} dependencies: @@ -4251,7 +4257,6 @@ packages: /csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - dev: false /culori@4.0.2: resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==} @@ -4669,11 +4674,6 @@ packages: engines: {node: '>=0.3.1'} dev: false - /direction@2.0.1: - resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} - hasBin: true - dev: false - /dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} dev: false @@ -4967,6 +4967,13 @@ packages: resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} dev: false + /fast-xml-parser@5.3.7: + resolution: {integrity: sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==} + hasBin: true + dependencies: + strnum: 2.1.2 + dev: false + /fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} dependencies: @@ -5057,31 +5064,6 @@ packages: mime-types: 2.1.35 dev: false - /fraction.js@4.3.7: - resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} - dev: false - - /framer-motion@12.23.0(react-dom@19.1.0)(react@19.1.0): - resolution: {integrity: sha512-xf6NxTGAyf7zR4r2KlnhFmsRfKIbjqeBupEDBAaEtVIBJX96sAon00kMlsKButSIRwPSHjbRrAPnYdJJ9kyhbA==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - dependencies: - motion-dom: 12.22.0 - motion-utils: 12.19.0 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - tslib: 2.8.1 - dev: false - /fs-minipass@2.1.0: resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} engines: {node: '>= 8'} @@ -5360,26 +5342,6 @@ packages: unist-util-position: 5.0.0 dev: false - /hast-util-select@6.0.4: - resolution: {integrity: sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==} - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - bcp-47-match: 2.0.3 - comma-separated-tokens: 2.0.3 - css-selector-parser: 3.1.3 - devlop: 1.1.0 - direction: 2.0.1 - hast-util-has-property: 3.0.0 - hast-util-to-string: 3.0.1 - hast-util-whitespace: 3.0.0 - nth-check: 2.1.1 - property-information: 7.1.0 - space-separated-tokens: 2.0.2 - unist-util-visit: 5.0.0 - zwitch: 2.0.4 - dev: false - /hast-util-to-estree@3.1.3: resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} dependencies: @@ -5452,12 +5414,6 @@ packages: zwitch: 2.0.4 dev: false - /hast-util-to-string@3.0.1: - resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} - dependencies: - '@types/hast': 3.0.4 - dev: false - /hast-util-to-text@4.0.2: resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} dependencies: @@ -5472,11 +5428,6 @@ packages: dependencies: '@types/hast': 3.0.4 - /hast@1.0.0: - resolution: {integrity: sha512-vFUqlRV5C+xqP76Wwq2SrM0kipnmpxJm7OfvVXpB35Fp+Fn4MV+ozr+JZr5qFvyR1q/U+Foim2x+3P+x9S1PLA==} - deprecated: Renamed to rehype - dev: false - /hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} dependencies: @@ -6174,23 +6125,6 @@ packages: '@types/mdast': 4.0.4 dev: false - /mdast-util-toc@7.1.0: - resolution: {integrity: sha512-2TVKotOQzqdY7THOdn2gGzS9d1Sdd66bvxUyw3aNpWfcPXCLYSJCCgfPy30sEtuzkDraJgqF35dzgmz6xlvH/w==} - dependencies: - '@types/mdast': 4.0.4 - '@types/ungap__structured-clone': 1.2.0 - '@ungap/structured-clone': 1.3.0 - github-slugger: 2.0.0 - mdast-util-to-string: 4.0.0 - unist-util-is: 6.0.0 - unist-util-visit: 5.0.0 - dev: false - - /mdast@3.0.0: - resolution: {integrity: sha512-xySmf8g4fPKMeC07jXGz971EkLbWAJ83s4US2Tj9lEdnZ142UP5grN73H1Xd3HzrdbU5o9GYYP/y8F9ZSwLE9g==} - deprecated: '`mdast` was renamed to `remark`' - dev: false - /mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} dev: false @@ -6665,36 +6599,6 @@ packages: ufo: 1.6.1 dev: false - /motion-dom@12.22.0: - resolution: {integrity: sha512-ooH7+/BPw9gOsL9VtPhEJHE2m4ltnhMlcGMhEqA0YGNhKof7jdaszvsyThXI6LVIKshJUZ9/CP6HNqQhJfV7kw==} - dependencies: - motion-utils: 12.19.0 - dev: false - - /motion-utils@12.19.0: - resolution: {integrity: sha512-BuFTHINYmV07pdWs6lj6aI63vr2N4dg0vR+td0rtrdpWOhBzIkEklZyLcvKBoEtwSqx8Jg06vUB5RS0xDiUybw==} - dev: false - - /motion@12.23.0(react-dom@19.1.0)(react@19.1.0): - resolution: {integrity: sha512-PPNwblArRH9GRC4F3KtOTiIaYd+mtp324vYq3HIL+ueseoAVqPRK5TPFTAQBcIprfVd0NWo3DLzZSiyWaYFXXQ==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - dependencies: - framer-motion: 12.23.0(react-dom@19.1.0)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - tslib: 2.8.1 - dev: false - /mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -6748,11 +6652,6 @@ packages: engines: {node: '>=0.10.0'} dev: false - /normalize-range@0.1.2: - resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} - engines: {node: '>=0.10.0'} - dev: false - /nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} dependencies: @@ -6969,24 +6868,6 @@ packages: points-on-curve: 0.2.0 dev: false - /postcss-nested@6.2.0(postcss@8.5.6): - resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.2.14 - dependencies: - postcss: 8.5.6 - postcss-selector-parser: 6.1.2 - dev: false - - /postcss-selector-parser@6.1.2: - resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} - engines: {node: '>=4'} - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - dev: false - /postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} dev: false @@ -7137,19 +7018,6 @@ packages: tslib: 2.8.1 dev: false - /react-tweet@3.2.2(react-dom@19.1.0)(react@19.1.0): - resolution: {integrity: sha512-hIkxAVPpN2RqWoDEbo3TTnN/pDcp9/Jb6pTgiA4EbXa9S+m2vHIvvZKHR+eS0PDIsYqe+zTmANRa5k6+/iwGog==} - peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - dependencies: - '@swc/helpers': 0.5.17 - clsx: 2.1.1 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - swr: 2.3.4(react@19.1.0) - dev: false - /react@19.1.0: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} @@ -7214,33 +7082,6 @@ packages: regex-utilities: 2.3.0 dev: false - /rehype-callouts@2.1.1: - resolution: {integrity: sha512-tPaywsSPUzgUJg+anafzr6wdXuWL6YQMoilNueO8ehYf1DWZMYH0bpPMuai5RUNdtcmQNOvOhwHLhh8p8b09nA==} - engines: {node: ^20.0.0 || >=22.0.0, pnpm: ^10.0.0} - dependencies: - '@types/hast': 3.0.4 - hast-util-from-html: 2.0.3 - hast-util-is-element: 3.0.0 - hastscript: 9.0.1 - unist-util-visit: 5.0.0 - dev: false - - /rehype-code-group@0.2.4(typescript@5.8.3): - resolution: {integrity: sha512-ExGjvA5dZz+UiTyIRWGclk1RDuTjqMiC0JxxSpcQ8W9AHWaJmzdyzQB4MiofnzRc2SCTvXDzuFhd1Co5TLMKmA==} - engines: {node: '>=20.0.0'} - peerDependencies: - typescript: '>=5.0.4' - peerDependenciesMeta: - typescript: - optional: true - dependencies: - hast-util-to-string: 3.0.1 - rehype: 13.0.2 - typescript: 5.8.3 - unified: 11.0.5 - unist-util-visit: 5.0.0 - dev: false - /rehype-external-links@3.0.0: resolution: {integrity: sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==} dependencies: @@ -7403,13 +7244,6 @@ packages: unified: 11.0.5 dev: false - /remark-toc@9.0.0: - resolution: {integrity: sha512-KJ9txbo33GjDAV1baHFze7ij4G8c7SGYoY8Kzsm2gzFpbhL/bSoVpMMzGa3vrNDSWASNd/3ppAqL7cP2zD6JIA==} - dependencies: - '@types/mdast': 4.0.4 - mdast-util-toc: 7.1.0 - dev: false - /require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -7698,6 +7532,10 @@ packages: engines: {node: '>=0.10.0'} dev: false + /strnum@2.1.2: + resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + dev: false + /style-mod@4.1.2: resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==} dev: false @@ -7746,16 +7584,6 @@ packages: sax: 1.4.3 dev: false - /swr@2.3.4(react@19.1.0): - resolution: {integrity: sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==} - peerDependencies: - react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - dependencies: - dequal: 2.0.3 - react: 19.1.0 - use-sync-external-store: 1.5.0(react@19.1.0) - dev: false - /tabbable@6.2.0: resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} dev: false @@ -7903,7 +7731,6 @@ packages: /undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - dev: false /undici@6.21.3: resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} @@ -8135,18 +7962,6 @@ packages: tslib: 2.8.1 dev: false - /use-sync-external-store@1.5.0(react@19.1.0): - resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - dependencies: - react: 19.1.0 - dev: false - - /util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - dev: false - /uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -8269,11 +8084,11 @@ packages: dependencies: '@types/node': 24.10.0 esbuild: 0.25.5 - fdir: 6.4.6(picomatch@4.0.2) - picomatch: 4.0.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 postcss: 8.5.6 rollup: 4.44.1 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 optionalDependencies: fsevents: 2.3.3 dev: false diff --git a/src/assets/css/global.css b/src/assets/css/global.css index 8dd702b..b1b57ae 100644 --- a/src/assets/css/global.css +++ b/src/assets/css/global.css @@ -169,5 +169,21 @@ ::view-transition-old(root), ::view-transition-new(root) { - animation-duration: 0.3s !important; + animation: none !important; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + + ::view-transition-old(root), + ::view-transition-new(root) { + animation: none !important; + } } diff --git a/src/content.config.ts b/src/content.config.ts index 32ac681..2fe1fb4 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -79,6 +79,8 @@ const blog = defineCollection({ permalink: z.string().optional(), thumbnail: z.string().optional(), authors: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), + draft: z.boolean().optional().default(false), publishedAt: z.string().optional(), }), }); diff --git a/src/hooks/build-doc.ts b/src/hooks/build-doc.ts index 5485317..4f8eb75 100644 --- a/src/hooks/build-doc.ts +++ b/src/hooks/build-doc.ts @@ -5,43 +5,101 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { generateThumbnail } from "../lib/components/content/thumbnail"; +async function renderThumbnail(svg: string, outputPath: string): Promise { + const resvg = new Resvg(svg, { + background: "transparent", + fitTo: { mode: "width", value: 960 }, + }); + const png = resvg.render(); + await writeFile(outputPath, png.asPng()); +} + export function buildDocIntegration(): AstroIntegration { return { name: "@explainer/renderer", hooks: { "astro:build:done": async ({ pages, logger }) => { logger.info("Starting thumbnail generation"); + + // Generate doc thumbnails const docPaths = pages .filter((element) => element.pathname.startsWith("docs/")) .map((element) => element.pathname); await Promise.all( docPaths.map(async (path) => { - const file = join(path.replace("docs/", "").replace(/\/$/, "")); - const { data } = await readFile( - join(process.cwd(), "content/docs", `${file}.mdx`), - "utf8", - ).then(matter); - - const thumbnail = await generateThumbnail( - file.split("/").at(0), - data.title, - data.description, - ); - - const location = join(process.cwd(), "dist", "docs"); - await mkdir(join(location, file), { - recursive: true, - }); - - const resvg = new Resvg(thumbnail, { - background: "transparent", - fitTo: { mode: "width", value: 960 }, - }); - - const png = resvg.render(); - await writeFile(join(location, file, "thumbnail.png"), png.asPng()); - logger.info(`Thumbnail generated for ${file}`); + try { + const file = join(path.replace("docs/", "").replace(/\/$/, "")); + const { data } = await readFile( + join(process.cwd(), "content/docs", `${file}.mdx`), + "utf8", + ).then(matter); + + const thumbnail = await generateThumbnail( + file.split("/").at(0), + data.title, + data.description, + ); + + const location = join(process.cwd(), "dist", "docs"); + await mkdir(join(location, file), { recursive: true }); + await renderThumbnail( + thumbnail, + join(location, file, "thumbnail.png"), + ); + logger.info(`Thumbnail generated for docs/${file}`); + } catch (error) { + logger.warn(`Failed to generate thumbnail for ${path}: ${error}`); + } + }), + ); + + // Generate blog thumbnails + const blogPaths = pages + .filter((element) => element.pathname.startsWith("blog/")) + .filter((element) => element.pathname !== "blog/") + .map((element) => element.pathname); + + await Promise.all( + blogPaths.map(async (path) => { + try { + const slug = path.replace("blog/", "").replace(/\/$/, ""); + + // Find the matching blog MDX file by permalink + const { readdirSync, readFileSync } = await import("node:fs"); + const blogDir = join(process.cwd(), "content/blog"); + const files = readdirSync(blogDir).filter( + (f) => f.endsWith(".mdx") || f.endsWith(".md"), + ); + + let blogData: { title?: string; description?: string } | null = + null; + for (const file of files) { + const content = readFileSync(join(blogDir, file), "utf8"); + const { data } = matter(content); + if (data.permalink === slug) { + blogData = data; + break; + } + } + + if (!blogData) return; + + const thumbnail = await generateThumbnail( + "Blog", + blogData.title, + blogData.description, + ); + + const location = join(process.cwd(), "dist", "blog", slug); + await mkdir(location, { recursive: true }); + await renderThumbnail(thumbnail, join(location, "thumbnail.png")); + logger.info(`Thumbnail generated for blog/${slug}`); + } catch (error) { + logger.warn( + `Failed to generate blog thumbnail for ${path}: ${error}`, + ); + } }), ); }, diff --git a/src/lib/components/content/card-group/card-group.astro b/src/lib/components/content/card-group/card-group.astro index 8cff351..83253a0 100644 --- a/src/lib/components/content/card-group/card-group.astro +++ b/src/lib/components/content/card-group/card-group.astro @@ -20,6 +20,6 @@ const gridCols = { }; --- -
+
diff --git a/src/lib/components/content/code-group/code-group.tsx b/src/lib/components/content/code-group/code-group.tsx index 800c195..e7d1f96 100644 --- a/src/lib/components/content/code-group/code-group.tsx +++ b/src/lib/components/content/code-group/code-group.tsx @@ -19,11 +19,11 @@ export default function CodeGroupComponent( >([]); const containerRef = useRef(null); - const parseProp = (prop: any) => { + const parseProp = (prop: string | string[] | undefined): string[] => { if (Array.isArray(prop)) return prop; if (typeof prop === "string") { try { - return JSON.parse(prop); + return JSON.parse(prop) as string[]; } catch { return []; } @@ -140,7 +140,7 @@ export default function CodeGroupComponent( return (
- {tabsData.map((tab: any, i: number) => ( + {tabsData.map((tab, i) => ( Documentations - {props.items.map((element) => ( - setActiveTeam(team)} - className="gap-2 p-2 text-muted" - > -
- -
- {element.data.label} -
- ))} + {props.items.map((element) => { + const href = getFirstPageHref(element); + const isCurrent = element.id === props.current.id; + const icon = isDocSection(element) + ? (element.data.icon ?? "lucide:book-open") + : (element.data.icon ?? "lucide:file-text"); + + return ( + + +
+ +
+ {"label" in element.data + ? element.data.label + : element.data.title} +
+
+ ); + })}
); diff --git a/src/lib/components/elements/navbar.tsx b/src/lib/components/elements/navbar.tsx index 816ded9..8c09a35 100644 --- a/src/lib/components/elements/navbar.tsx +++ b/src/lib/components/elements/navbar.tsx @@ -46,7 +46,9 @@ const ListItem = forwardRef< {...props} >
- {icon && } + {icon && ( + + )} {title}

@@ -59,9 +61,24 @@ const ListItem = forwardRef< }); ListItem.displayName = "ListItem"; +import type { DocPage, DocSection } from "@/utils"; + +type NavbarDocEntry = { + id: string; + collection: string; + data: DocSection["data"]; + icon: string; + label: string; + children: DocPage[]; +}; + +type SearchableDocEntry = DocSection & { + children: (DocPage & { description: string })[]; +}; + type NavbarProps = { - docs: any[]; - searchableDoc: any[]; + docs: NavbarDocEntry[]; + searchableDoc: SearchableDocEntry[]; }; export default function Navbar(props: NavbarProps) { @@ -88,23 +105,26 @@ export default function Navbar(props: NavbarProps) { {props.docs.map((element, index) => ( - + {element.data.label}

    - {element.children - // .filter((item: any) => item.visible) - .map((item: any) => ( - - {item.data.description} - - ))} + {element.children.map((item) => ( + + {item.data.description} + + ))}
@@ -196,22 +216,25 @@ export default function Navbar(props: NavbarProps) { {props.docs.map((element, index) => (
- + {element.label}
diff --git a/src/lib/components/elements/search-command.tsx b/src/lib/components/elements/search-command.tsx index 97876fd..b96f4da 100644 --- a/src/lib/components/elements/search-command.tsx +++ b/src/lib/components/elements/search-command.tsx @@ -10,6 +10,7 @@ import { CommandSeparator, } from "@/components/ui/command"; import { useTheme } from "@/hooks/use-theme"; +import type { DocPage, DocSection } from "@/utils"; import { Icon } from "@iconify/react"; import { InputGroup, @@ -18,8 +19,12 @@ import { } from "../ui/input-group"; import { Kbd, KbdGroup } from "../ui/kbd"; +type SearchableDocEntry = DocSection & { + children: (DocPage & { description: string })[]; +}; + type Props = { - items: any[]; + items: SearchableDocEntry[]; }; export function SearchCommand(props: Props) { @@ -45,7 +50,7 @@ export function SearchCommand(props: Props) { onClick={() => setOpen(true)} > - + @@ -64,19 +69,27 @@ export function SearchCommand(props: Props) { {props.items.map((doc) => { return ( - {doc.children.map((page: any) => ( - - -
- - - {page.data.title} - -
-

- {page.content.remarkPluginFrontmatter.description} -

-
+ {doc.children.map((page) => ( + { + setOpen(false); + window.location.href = `/docs/${page.id}`; + }} + className="cursor-pointer" + > +
+ + + {page.data.title} + +
+

{page.description}

))}
@@ -85,32 +98,38 @@ export function SearchCommand(props: Props) { - - + { + setTheme("light"); + setOpen(false); + }} + className="cursor-pointer" + > + + Light - - + { + setTheme("dark"); + setOpen(false); + }} + className="cursor-pointer" + > + + Dark - - + { + setTheme("system"); + setOpen(false); + }} + className="cursor-pointer" + > + + System diff --git a/src/lib/components/elements/sidebar.tsx b/src/lib/components/elements/sidebar.tsx new file mode 100644 index 0000000..5fdb548 --- /dev/null +++ b/src/lib/components/elements/sidebar.tsx @@ -0,0 +1,60 @@ +import { isDocSection, type DocSection, type DocTreeNode } from "@/utils"; +import { useEffect, useState } from "react"; +import Collapsible from "./collapsible"; +import DocSwitcher from "./doc-switcher"; + +function usePathname(initial: string) { + const [pathname, setPathname] = useState(initial); + + useEffect(() => { + const update = () => setPathname(window.location.pathname); + document.addEventListener("astro:after-swap", update); + return () => document.removeEventListener("astro:after-swap", update); + }, []); + + return pathname; +} + +function flattenPages(nodes: DocTreeNode[]): { id: string }[] { + return nodes.flatMap((node) => + isDocSection(node) ? flattenPages(node.children) : [node], + ); +} + +type Props = { + documentations: DocTreeNode[]; + currentPathname: string; +}; + +export default function Sidebar(props: Props) { + const pathname = usePathname(props.currentPathname); + + const currentCollection = props.documentations.find((element) => { + if (!isDocSection(element)) return false; + const pages = flattenPages(element.children); + return pages.some((item) => pathname.startsWith(`/docs/${item.id}`)); + }) as DocSection | undefined; + + return ( + <> + {props.documentations.length > 1 && currentCollection && ( +
+ +
+ )} + +
+ {currentCollection?.children.map((children) => ( + + ))} +
+ + ); +} diff --git a/src/lib/layouts/BaseLayout.astro b/src/lib/layouts/BaseLayout.astro index 72a372a..2994f42 100644 --- a/src/lib/layouts/BaseLayout.astro +++ b/src/lib/layouts/BaseLayout.astro @@ -20,20 +20,15 @@ const docs = loaded.map((element) => { }; }); -const searchableDoc = await Promise.all( - loaded - .flatMap((elements) => elements.children) - .map(async (element) => { - const children = await Promise.all( - flattenDocs(element.children).map(async (entry) => { - const a = await astroContent.render(entry); - return { ...entry, content: a }; - }), - ); - - return { ...element, children }; - }), -); +const searchableDoc = loaded + .flatMap((elements) => elements.children) + .map((element) => ({ + ...element, + children: flattenDocs(element.children).map((entry) => ({ + ...entry, + description: entry.data.description, + })), + })); --- @@ -45,10 +40,17 @@ const searchableDoc = await Promise.all( {title} + + - + @@ -61,7 +63,15 @@ const searchableDoc = await Promise.all( - + + + + + + + + Skip to main content +
{ @@ -87,10 +103,13 @@ const searchableDoc = await Promise.all( return true; })} searchableDoc={searchableDoc} - client:only + client:load + transition:persist /> - -
+
+ +
+
diff --git a/src/lib/layouts/DocsLayout.astro b/src/lib/layouts/DocsLayout.astro index bfc128f..8bb66ca 100644 --- a/src/lib/layouts/DocsLayout.astro +++ b/src/lib/layouts/DocsLayout.astro @@ -1,25 +1,15 @@ --- -import { Icon } from "@iconify/react"; -import clsx from "clsx"; import "../../assets/css/global.css"; import "../../assets/css/markdown.css"; import BaseLayout from "./BaseLayout.astro"; import { useDocumentation } from "@/utils"; import * as astroContent from "astro:content"; -import Collapsible from "@/components/elements/collapsible"; -import DocSwitcher from "@/components/elements/doc-switcher"; +import Sidebar from "@/components/elements/sidebar"; import config from "../../../explainer.config"; -const { load, flattenDocs } = useDocumentation(astroContent); +const { load } = useDocumentation(astroContent); const documentations = await load(); -const currentCollection = documentations.find((element) => { - const pages = flattenDocs(element.children); - return pages.find((item) => - Astro.url.pathname.startsWith(`/docs/${item.id}`), - ); -}); - const { id, title, description } = Astro.props; --- @@ -33,124 +23,42 @@ const { id, title, description } = Astro.props;
-
+
- In the documentation -
- - - -
+ Navigation + + +
-
-
- - -
-
+
+
diff --git a/src/lib/plugins/parser/plugin.ts b/src/lib/plugins/parser/plugin.ts index 1069ddf..2e9d249 100644 --- a/src/lib/plugins/parser/plugin.ts +++ b/src/lib/plugins/parser/plugin.ts @@ -1,13 +1,20 @@ import type { Root } from "mdast"; import { visit } from "unist-util-visit"; +type DirectiveNode = { + type: string; + name: string; + attributes?: Record; + data?: Record; +}; + export default function remarkBlockParser() { return (tree: Root) => { visit(tree, (node) => { if (node.type !== "containerDirective" && node.type !== "leafDirective") return; - const n = node as any; + const n = node as unknown as DirectiveNode; const data = n.data || (n.data = {}); data.hName = n.name; diff --git a/src/lib/plugins/shiki/transformer-meta-label.ts b/src/lib/plugins/shiki/transformer-meta-label.ts index 8d09875..dafcb91 100644 --- a/src/lib/plugins/shiki/transformer-meta-label.ts +++ b/src/lib/plugins/shiki/transformer-meta-label.ts @@ -1,3 +1,5 @@ +import type { ShikiTransformer } from "shiki"; + /** * Transformer to extract label from meta string * @@ -6,11 +8,11 @@ * pnpm install * ``` */ -export default function transformerMetaLabel() { +export default function transformerMetaLabel(): ShikiTransformer { return { name: "transformer-meta-label", - pre(node: any) { - const meta = (this as any).options.meta as { __raw?: string } | undefined; + pre(node) { + const meta = this.options.meta as { __raw?: string } | string | undefined; const metaString = typeof meta === "string" ? meta : meta?.__raw; if (!metaString) return; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 21fe35e..4b1f4c9 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,6 @@ import type { getCollection, getEntry } from "astro:content"; import { clsx, type ClassValue } from "clsx"; +import type { ComponentType } from "react"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { @@ -13,6 +14,73 @@ export type HeadingNode = { children: HeadingNode[]; }; +export function buildHeadingTree( + headings: { depth: number; slug: string; text: string }[], +): HeadingNode[] { + const result: HeadingNode[] = []; + let currentH2: HeadingNode | null = null; + + for (const heading of headings) { + const node: HeadingNode = { + depth: heading.depth, + slug: heading.slug, + text: heading.text, + children: [], + }; + + if (heading.depth === 2) { + currentH2 = node; + result.push(node); + } else if (heading.depth === 3 && currentH2) { + currentH2.children.push(node); + } + } + + return result; +} + +/** Data shape for doc page entries (from `docs` collection) */ +export type DocPageData = { + title: string; + description: string; + permalink?: string; + icon?: string; + visibility: string[]; +}; + +/** Data shape for doc section defaults (from `docDefaults`/`deepDocDefaults`) */ +export type DocDefaultData = { + label: string; + description: string; + permalink: string; + icon?: string; + collection: string[]; +}; + +/** A leaf documentation page */ +export type DocPage = { + id: string; + collection: string; + data: DocPageData; + filePath?: string; +}; + +/** A section node that can contain children (pages or nested sections) */ +export type DocSection = { + id: string; + collection: string; + data: DocDefaultData; + children: DocTreeNode[]; +}; + +/** A node in the documentation tree: either a section or a leaf page */ +export type DocTreeNode = DocSection | DocPage; + +/** Type guard: checks if a doc tree node is a section (has children) */ +export function isDocSection(node: DocTreeNode): node is DocSection { + return "children" in node; +} + type ExplainerConfig = { repository?: string; projectName: string; @@ -43,9 +111,10 @@ type ExplainerConfig = { }[]; content: { icons: Record; - components: { - [key: string]: (...props: any[]) => any; - }; + components: Record< + string, + string | ComponentType | ((...args: unknown[]) => unknown) + >; }; }; @@ -64,20 +133,36 @@ export function useDocumentation(astro: { getCollection: typeof getCollection; getEntry: typeof getEntry; }) { - async function buildTree(root: string) { + async function buildTree(root: string): Promise { const { join } = await import("node:path"); const { readdir, stat } = await import("node:fs/promises"); - let _default: { children: any[] } = { children: [] }; - const pages: any[] = []; + let _default: DocSection = { + id: "", + collection: "", + data: { label: "", description: "", permalink: "", collection: [] }, + children: [], + }; + const pages: DocPage[] = []; - const elements = await readdir(root); + let elements: string[]; + try { + elements = await readdir(root); + } catch (error) { + console.error(`[explainer] Failed to read directory: ${root}`, error); + return _default; + } for (const element of elements) { const currentElementPath = join(root, element); const elementStat = await stat(currentElementPath); if (elementStat.isDirectory()) { const currentObj = await buildTree(join(root, element)); + if (!currentObj.data.label) { + console.warn( + `[explainer] Missing or empty _default.mdx in ${join(root, element)}. Section will have no label.`, + ); + } _default.children.push(currentObj); } @@ -92,31 +177,35 @@ export function useDocumentation(astro: { join(location, "_default"), ); - _default = { ...astroElement, children: [] }; + if (astroElement) { + _default = { ...astroElement, children: [] } as DocSection; + } } else { const location = root.replace( join(process.cwd(), "content", "docs/"), "", ); - const [filename, __] = element.split("."); + const [filename] = element.split("."); const astroElement = await astro.getEntry( "docs", join(location, filename), ); - pages.push(astroElement); + if (astroElement) { + pages.push(astroElement as unknown as DocPage); + } } } } - if ((_default as any)?.data?.collection) { - for (const collection of (_default as any).data.collection) { + if (_default.data.collection) { + for (const collectionName of _default.data.collection) { const location = root.replace( join(process.cwd(), "content", "docs/"), "", ); - const targetId = join(location, collection); + const targetId = join(location, collectionName); const targetPage = pages.find((page) => page.id === targetId); if (targetPage) { @@ -126,21 +215,21 @@ export function useDocumentation(astro: { } for (const folder of _default.children) { - if (folder.data.collection) { - for (const collection of folder.data.collection) { - const index = folder.data.collection.indexOf(collection); + if (isDocSection(folder) && folder.data.collection) { + for (const collectionName of folder.data.collection) { + const index = folder.data.collection.indexOf(collectionName); const targetId = join( folder.id.replace("/_default", ""), - `${collection}/_default`, + `${collectionName}/_default`, ); - const targetPage = folder.children.find( - (page: any) => page.id === targetId, + const targetChild = folder.children.find( + (page) => page.id === targetId, ); - if (targetPage) { - const folderIndex = folder.children.indexOf(targetPage); + if (targetChild) { + const folderIndex = folder.children.indexOf(targetChild); folder.children.splice(folderIndex, 1); - folder.children.splice(index, 0, targetPage); + folder.children.splice(index, 0, targetChild); } } } @@ -149,7 +238,7 @@ export function useDocumentation(astro: { return _default; } - async function load(): Promise { + async function load(): Promise { const { join } = await import("node:path"); const root = join(process.cwd(), "content", "docs"); @@ -159,32 +248,36 @@ export function useDocumentation(astro: { async function generateStaticPaths() { const docs = await load(); - function flattenChildren(children: any[]): any[] { + function flattenChildren( + children: DocTreeNode[], + ): { params: { slug: string }; props: { element: DocTreeNode } }[] { return children.flatMap((child) => { return [ { params: { slug: child.id }, props: { element: child }, }, - ...(child.children && child.children.length + ...(isDocSection(child) && child.children.length ? flattenChildren(child.children) : []), ]; }); } - return docs.flatMap((root) => flattenChildren(root.children)); + return docs.flatMap((root) => + isDocSection(root) ? flattenChildren(root.children) : [], + ); } - function flattenDocs(elements: any[]) { - const pages: any[] = []; + function flattenDocs(elements: DocTreeNode[]): DocPage[] { + const pages: DocPage[] = []; - function flatten(children: any[]) { + function flatten(children: DocTreeNode[]) { for (const element of children) { - if (element.children && element.children.length) { + if (isDocSection(element) && element.children.length) { flatten(element.children); } else { - pages.push(element); + pages.push(element as DocPage); } } } diff --git a/src/pages/404.astro b/src/pages/404.astro new file mode 100644 index 0000000..409020e --- /dev/null +++ b/src/pages/404.astro @@ -0,0 +1,30 @@ +--- +import BaseLayout from "@/layouts/BaseLayout.astro"; +import config from "../../explainer.config"; +--- + + +
+
+

404

+

Page not found

+

+ The page you're looking for doesn't exist or has been moved. +

+ +
+
+
diff --git a/src/pages/blog/[slug].astro b/src/pages/blog/[slug].astro index 1baef50..a1c1697 100644 --- a/src/pages/blog/[slug].astro +++ b/src/pages/blog/[slug].astro @@ -7,6 +7,7 @@ import { BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import BlogLayout from "@/layouts/BlogLayout.astro"; +import { buildHeadingTree } from "@/utils"; import { getCollection, render, type CollectionEntry } from "astro:content"; import { NewspaperIcon } from "lucide-react"; import { DateTime } from "luxon"; @@ -38,36 +39,6 @@ const availableHeadings = headings.filter((heading) => [2, 3].includes(heading.depth), ); -type HeadingNode = { - depth: number; - slug: string; - text: string; - children: HeadingNode[]; -}; - -function buildHeadingTree(headings: typeof availableHeadings): HeadingNode[] { - const result: HeadingNode[] = []; - let currentH2: HeadingNode | null = null; - - for (const heading of headings) { - const node: HeadingNode = { - depth: heading.depth, - slug: heading.slug, - text: heading.text, - children: [], - }; - - if (heading.depth === 2) { - currentH2 = node; - result.push(node); - } else if (heading.depth === 3 && currentH2) { - currentH2.children.push(node); - } - } - - return result; -} - const headingTree = buildHeadingTree(availableHeadings); --- diff --git a/src/pages/docs/[...slug].astro b/src/pages/docs/[...slug].astro index d8e91f0..6d657f5 100644 --- a/src/pages/docs/[...slug].astro +++ b/src/pages/docs/[...slug].astro @@ -11,7 +11,7 @@ import DocsLayout from "@/layouts/DocsLayout.astro"; import { render, type CollectionEntry } from "astro:content"; import config from "../../../explainer.config"; -import { useDocumentation, type HeadingNode } from "../../lib/utils"; +import { useDocumentation, buildHeadingTree } from "../../lib/utils"; import * as astroContent from "astro:content"; import DocNavigationWrapper from "@/components/elements/DocNavigationWrapper.astro"; import { Icon } from "@iconify/react"; @@ -38,29 +38,6 @@ const availableHeadings = headings.filter((heading) => [2, 3].includes(heading.depth), ); -function buildHeadingTree(headings: typeof availableHeadings): HeadingNode[] { - const result: HeadingNode[] = []; - let currentH2: HeadingNode | null = null; - - for (const heading of headings) { - const node: HeadingNode = { - depth: heading.depth, - slug: heading.slug, - text: heading.text, - children: [], - }; - - if (heading.depth === 2) { - currentH2 = node; - result.push(node); - } else if (heading.depth === 3 && currentH2) { - currentH2.children.push(node); - } - } - - return result; -} - const headingTree = buildHeadingTree(availableHeadings); function pascalCase(str: string) { @@ -102,6 +79,54 @@ function pascalCase(str: string) {
+ { + headingTree.length ? ( +
+ + On this page + + + + + +
+ ) : null + } +
@@ -121,7 +146,7 @@ function pascalCase(str: string) { class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors px-2.5 py-1.5 text-xs gap-1.5 text-muted-foreground hover:text-default disabled:text-muted aria-disabled:text-muted focus:outline-none focus-visible:ring-inset focus-visible:ring-2 focus-visible:ring-inverted" > @@ -134,7 +159,7 @@ function pascalCase(str: string) { target="_blank" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors px-2.5 py-1.5 text-xs gap-1.5 text-muted-foreground hover:text-default disabled:text-muted aria-disabled:text-muted focus:outline-none focus-visible:ring-inset focus-visible:ring-2 focus-visible:ring-inverted" > - + Edit this page on GitHub
diff --git a/src/pages/rss.xml.ts b/src/pages/rss.xml.ts new file mode 100644 index 0000000..d04f8af --- /dev/null +++ b/src/pages/rss.xml.ts @@ -0,0 +1,30 @@ +import rss from "@astrojs/rss"; +import type { APIContext } from "astro"; +import { getCollection } from "astro:content"; +import config from "../../explainer.config"; + +export async function GET(context: APIContext) { + const posts = await getCollection("blog"); + const publishedPosts = posts + .filter((post) => post.id !== "index") + .filter((post) => !post.data.draft) + .filter( + (post) => + post.data.publishedAt && + new Date(post.data.publishedAt) <= new Date(), + ); + + return rss({ + title: config.seo.title, + description: config.seo.description, + site: context.site ?? "https://example.com", + items: publishedPosts.map((post) => ({ + title: post.data.title, + description: post.data.description, + pubDate: post.data.publishedAt + ? new Date(post.data.publishedAt) + : new Date(), + link: `/blog/${post.data.permalink}`, + })), + }); +} From 0a3caa0d5584fee993b958522a87b00f65fedfd9 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 23 Feb 2026 11:18:24 +0100 Subject: [PATCH 2/9] wip --- src/hooks/build-doc.ts | 138 +++++++++++++++++++++-------------------- 1 file changed, 71 insertions(+), 67 deletions(-) diff --git a/src/hooks/build-doc.ts b/src/hooks/build-doc.ts index 4f8eb75..7c1a364 100644 --- a/src/hooks/build-doc.ts +++ b/src/hooks/build-doc.ts @@ -1,7 +1,7 @@ import { Resvg } from "@resvg/resvg-js"; import type { AstroIntegration } from "astro"; import matter from "gray-matter"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { generateThumbnail } from "../lib/components/content/thumbnail"; @@ -21,84 +21,88 @@ export function buildDocIntegration(): AstroIntegration { "astro:build:done": async ({ pages, logger }) => { logger.info("Starting thumbnail generation"); - // Generate doc thumbnails - const docPaths = pages - .filter((element) => element.pathname.startsWith("docs/")) - .map((element) => element.pathname); + // Build blog metadata map upfront (permalink β†’ { title, description }) + const blogDir = join(process.cwd(), "content/blog"); + const allBlogFiles = await readdir(blogDir); + const blogFiles = allBlogFiles.filter( + (f) => (f.endsWith(".mdx") || f.endsWith(".md")) && f !== "index.mdx", + ); + const blogMap = new Map< + string, + { title: string; description: string; thumbnail?: string } + >(); + for (const file of blogFiles) { + const content = await readFile(join(blogDir, file), "utf8"); + const { data } = matter(content); + if (data.permalink) { + blogMap.set(data.permalink, { + title: data.title, + description: data.description, + thumbnail: data.thumbnail, + }); + } + } - await Promise.all( - docPaths.map(async (path) => { - try { - const file = join(path.replace("docs/", "").replace(/\/$/, "")); - const { data } = await readFile( + // Collect doc thumbnail tasks + const docTasks = pages + .filter((element) => element.pathname.startsWith("docs/")) + .map((element) => async () => { + const file = element.pathname + .replace("docs/", "") + .replace(/\/$/, ""); + const { data } = matter( + await readFile( join(process.cwd(), "content/docs", `${file}.mdx`), "utf8", - ).then(matter); + ), + ); - const thumbnail = await generateThumbnail( - file.split("/").at(0), - data.title, - data.description, - ); + const thumbnail = await generateThumbnail( + file.split("/").at(0), + data.title, + data.description, + ); - const location = join(process.cwd(), "dist", "docs"); - await mkdir(join(location, file), { recursive: true }); - await renderThumbnail( - thumbnail, - join(location, file, "thumbnail.png"), - ); - logger.info(`Thumbnail generated for docs/${file}`); - } catch (error) { - logger.warn(`Failed to generate thumbnail for ${path}: ${error}`); - } - }), - ); + const location = join(process.cwd(), "dist", "docs"); + await mkdir(join(location, file), { recursive: true }); + await renderThumbnail( + thumbnail, + join(location, file, "thumbnail.png"), + ); + logger.info(`Thumbnail generated for docs/${file}`); + }); - // Generate blog thumbnails - const blogPaths = pages + // Collect blog thumbnail tasks + const blogTasks = pages .filter((element) => element.pathname.startsWith("blog/")) .filter((element) => element.pathname !== "blog/") - .map((element) => element.pathname); - - await Promise.all( - blogPaths.map(async (path) => { - try { - const slug = path.replace("blog/", "").replace(/\/$/, ""); + .map((element) => async () => { + const slug = element.pathname + .replace("blog/", "") + .replace(/\/$/, ""); + const blogData = blogMap.get(slug); + if (!blogData || blogData.thumbnail) return; - // Find the matching blog MDX file by permalink - const { readdirSync, readFileSync } = await import("node:fs"); - const blogDir = join(process.cwd(), "content/blog"); - const files = readdirSync(blogDir).filter( - (f) => f.endsWith(".mdx") || f.endsWith(".md"), - ); + const thumbnail = await generateThumbnail( + "Blog", + blogData.title, + blogData.description, + ); - let blogData: { title?: string; description?: string } | null = - null; - for (const file of files) { - const content = readFileSync(join(blogDir, file), "utf8"); - const { data } = matter(content); - if (data.permalink === slug) { - blogData = data; - break; - } - } + const location = join(process.cwd(), "dist", "blog", slug); + await mkdir(location, { recursive: true }); + await renderThumbnail(thumbnail, join(location, "thumbnail.png")); + logger.info(`Thumbnail generated for blog/${slug}`); + }); - if (!blogData) return; - - const thumbnail = await generateThumbnail( - "Blog", - blogData.title, - blogData.description, - ); - - const location = join(process.cwd(), "dist", "blog", slug); - await mkdir(location, { recursive: true }); - await renderThumbnail(thumbnail, join(location, "thumbnail.png")); - logger.info(`Thumbnail generated for blog/${slug}`); + // Run ALL thumbnails in a single batch so Vite module runner stays active + const allTasks = [...docTasks, ...blogTasks]; + await Promise.all( + allTasks.map(async (task) => { + try { + await task(); } catch (error) { - logger.warn( - `Failed to generate blog thumbnail for ${path}: ${error}`, - ); + logger.warn(`Failed to generate thumbnail: ${error}`); } }), ); From a6b47623797a9292764f62748365c040a7ee4127 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 23 Feb 2026 12:35:19 +0100 Subject: [PATCH 3/9] feat: enhance design --- content/blog/index.mdx | 29 +-- content/index.mdx | 165 +-------------- .../components/elements/blog-section.astro | 27 +++ src/lib/components/elements/cta-section.astro | 67 ++++++ .../elements/features-section.astro | 125 ++++++++++++ .../components/elements/hero-section.astro | 192 ++++++++++++++++++ .../elements/highlights-section.astro | 123 +++++++++++ .../elements/testimonials-section.astro | 93 +++++++++ .../components/ui/background-animation.tsx | 43 ++-- src/lib/layouts/Layout.astro | 2 +- src/pages/[...page].astro | 18 +- 11 files changed, 692 insertions(+), 192 deletions(-) create mode 100644 src/lib/components/elements/blog-section.astro create mode 100644 src/lib/components/elements/cta-section.astro create mode 100644 src/lib/components/elements/features-section.astro create mode 100644 src/lib/components/elements/hero-section.astro create mode 100644 src/lib/components/elements/highlights-section.astro create mode 100644 src/lib/components/elements/testimonials-section.astro diff --git a/content/blog/index.mdx b/content/blog/index.mdx index 42206c3..2086e8b 100644 --- a/content/blog/index.mdx +++ b/content/blog/index.mdx @@ -5,21 +5,24 @@ description: Lorem ipsum import config from "../../explainer.config"; -
-
-
-
- Get the help you need +
+
+
+
+
+ Get the help you need +
+

+ The {config.projectName} Blog +

+

+ Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem + cupidatat commodo. Elit sunt amet fugiat veniam occaecat fugiat. +

-

- The {config.projectName} Blog -

-

- Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem - cupidatat commodo. Elit sunt amet fugiat veniam occaecat fugiat. -

-
::articles + +
diff --git a/content/index.mdx b/content/index.mdx index 848382a..590fdc4 100644 --- a/content/index.mdx +++ b/content/index.mdx @@ -1,162 +1,9 @@ --- --- -import { BackgroundAnimation } from "@/components/ui/background-animation"; -import { Icon } from "@iconify/react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; - -
-
- -
-
-
- -

- Explain your ideas with Markdown -

-
- Quickly design your documentation and optimise it for search engine - optimisation to showcase your products. - - Make elegant documentation for your project with Explainer. -
-
- - -
-
-
-
-
-
-
-
- Built with - Astro - and - React -
-
- Optimised yet open to modification, Explainer gives you - everything you need to create perfect documentation. -
-
-
-
- -
-
-
- -
-
-
-
-
- Powered by Mineral -
-
- Easy to use and customisable, Explainer provides you with a multitude - of tools to enable you to create your documentation quickly without - wasting time on details; only your content matters. -
-
-
- Magnifique visuel propulsΓ© par UI -
-
- -
-
- -
-
-
-
-
- -
-
-
-

- Follow our blog for updates and news -

-

- Learn more about our latest developments. We share tips and tricks to - help you succeed.og posts and follow us -

-
-
- -
- ::articles -
-
+::hero-section +::features-section +::highlights-section +::testimonials-section +::cta-section +::blog-section diff --git a/src/lib/components/elements/blog-section.astro b/src/lib/components/elements/blog-section.astro new file mode 100644 index 0000000..2662f62 --- /dev/null +++ b/src/lib/components/elements/blog-section.astro @@ -0,0 +1,27 @@ +--- +import Articles from "@/components/elements/articles.astro"; +import config from "explainer.config"; + +const title = "From the blog"; +const description = `Latest news and tips from the ${config.projectName} team.`; +--- + +
+
+

+ Blog +

+

+ {title} +

+

+ {description} +

+
+ + +
diff --git a/src/lib/components/elements/cta-section.astro b/src/lib/components/elements/cta-section.astro new file mode 100644 index 0000000..ea8d862 --- /dev/null +++ b/src/lib/components/elements/cta-section.astro @@ -0,0 +1,67 @@ +--- +import { Icon } from "@iconify/react"; +import { Button } from "@/components/ui/button"; + +const title = "Ready to get started?"; +const description = + "Start building your documentation in minutes. Free, open source, and yours to customize."; + +const action = { + label: "Read the docs", + href: "/docs/documentation/getting-started/getting-started", +}; +--- + +
+
+ + +

+ {title} +

+

+ {description} +

+ +
+
diff --git a/src/lib/components/elements/features-section.astro b/src/lib/components/elements/features-section.astro new file mode 100644 index 0000000..7bdeb99 --- /dev/null +++ b/src/lib/components/elements/features-section.astro @@ -0,0 +1,125 @@ +--- +import { Icon } from "@iconify/react"; +import config from "explainer.config"; + +const title = "Everything you need, out of the box"; +const description = `${config.projectName} gives you the tools to focus on your content, not your tooling.`; + +const features = [ + { + icon: "mdi:language-markdown", + title: "Markdown & MDX", + description: + "Write content in Markdown or MDX with custom components, directives, and JSX support.", + }, + { + icon: "mdi:flash-outline", + title: "Lightning fast", + description: + "Static site generation for instant page loads. Zero JavaScript shipped by default.", + }, + { + icon: "mdi:code-braces", + title: "Rich code blocks", + description: + "Dual-theme syntax highlighting, line diffs, focus mode, and 60+ language icons.", + }, + { + icon: "mdi:magnify", + title: "Built-in search", + description: + "Command palette search so your readers find any page instantly.", + }, + { + icon: "mdi:search-web", + title: "SEO ready", + description: + "Auto-generated OG thumbnails, sitemap, robots.txt, and RSS feed.", + }, + { + icon: "mdi:theme-light-dark", + title: "Dark mode", + description: + "Full light and dark theme support with system preference detection.", + }, +]; +--- + +
+ + + + +
+
+

+ Features +

+

+ {title} +

+

+ {description} +

+
+ +
+ { + features.map((feature) => ( +
+
+ +
+
+

+ {feature.title} +

+

+ {feature.description} +

+
+
+ )) + } +
+
+
diff --git a/src/lib/components/elements/hero-section.astro b/src/lib/components/elements/hero-section.astro new file mode 100644 index 0000000..73ceffe --- /dev/null +++ b/src/lib/components/elements/hero-section.astro @@ -0,0 +1,192 @@ +--- +import { BackgroundAnimation } from "@/components/ui/background-animation"; +import { Icon } from "@iconify/react"; +import { Button } from "@/components/ui/button"; +import config from "explainer.config"; + +const badge = config.projectName; +const title = "Explain your ideas"; +const description = + "Quickly design your documentation and optimise it for search engine optimisation to showcase your products."; + +const primaryAction = { + label: "Getting Started", + href: "/docs/documentation/getting-started/getting-started", +}; +const secondaryAction = config.repository + ? { label: "View on GitHub", href: config.repository, icon: "mdi:github" } + : null; + +const socialProof = [ + { icon: "mdi:open-source-initiative", text: "Open source" }, + { icon: "mdi:license", text: "MIT License" }, + { icon: "mdi:download-outline", text: "1k+ downloads" }, +]; +--- + +
+
+
+ +
+ + + + + + + + +
+
+ +
+
+ { + badge && ( + + ) + } +

+ {title} +

+

+ {description} +

+
+ +
+
+ + { + secondaryAction && ( + + ) + } +
+
+ +
+ { + socialProof.map((item) => ( +
+ + {item.text} +
+ )) + } +
+
+
+
diff --git a/src/lib/components/elements/highlights-section.astro b/src/lib/components/elements/highlights-section.astro new file mode 100644 index 0000000..1f5ee1f --- /dev/null +++ b/src/lib/components/elements/highlights-section.astro @@ -0,0 +1,123 @@ +--- +import { Icon } from "@iconify/react"; +import config from "explainer.config"; + +const title = `Why ${config.projectName}?`; +const description = + "Built for developers who care about their users' experience."; + +const highlights = [ + { + icon: "mdi:rocket-launch", + title: "Ship docs in minutes, not days", + description: + "Clone the repo, write your Markdown, deploy. No complex setup, no build pipeline to configure. Your documentation is live before your coffee gets cold.", + span: 4, + }, + { + icon: "mdi:palette-swatch-variant", + title: "Your brand, your way", + description: + "Tailwind CSS design tokens let you match your documentation to your product identity with a few CSS variable changes.", + span: 2, + }, + { + icon: "mdi:puzzle-outline", + title: "Extensible by design", + description: + "Drop in React, Vue, or Svelte components directly in your Markdown. Build interactive examples, live demos, and API playgrounds.", + span: 2, + }, + { + icon: "mdi:chart-line", + title: "Built for growth", + description: + "From a single getting-started guide to hundreds of pages β€” the architecture scales with your project. Multi-collection docs, versioning-ready structure, and automatic navigation.", + span: 4, + }, +]; +--- + +
+ + + + +
+
+

+ Highlights +

+

+ {title} +

+

+ {description} +

+
+ +
+ { + highlights.map((item) => ( +
+
+ +
+
+

+ {item.title} +

+

+ {item.description} +

+
+
+ )) + } +
+
+
diff --git a/src/lib/components/elements/testimonials-section.astro b/src/lib/components/elements/testimonials-section.astro new file mode 100644 index 0000000..9c4598d --- /dev/null +++ b/src/lib/components/elements/testimonials-section.astro @@ -0,0 +1,93 @@ +--- +const title = "Loved by developers"; +const description = "See what developers are saying about their experience."; + +const testimonials = [ + { name: "Sarah Chen", username: "@sarahchen", body: "The best documentation framework I've used. Setup took 5 minutes and the output looks incredible.", img: "https://i.pravatar.cc/150?u=sarah" }, + { name: "Marcus Rivera", username: "@mrivera", body: "Finally, a docs tool that doesn't fight you. Markdown in, beautiful site out. Exactly what we needed.", img: "https://i.pravatar.cc/150?u=marcus" }, + { name: "Aisha Patel", username: "@aishap", body: "The built-in search and SEO features saved us weeks of work. Our docs rank on page one now.", img: "https://i.pravatar.cc/150?u=aisha" }, + { name: "Tom MΓΌller", username: "@tmueller", body: "Dark mode, code highlighting, view transitions β€” it has everything out of the box. No plugins needed.", img: "https://i.pravatar.cc/150?u=tom" }, + { name: "Elena Rossi", username: "@erossi", body: "We migrated from Docusaurus in a weekend. The MDX component system is so much more flexible.", img: "https://i.pravatar.cc/150?u=elena" }, + { name: "David Kim", username: "@dkim", body: "The developer experience is top notch. Hot reload is instant and the API reference integration is perfect.", img: "https://i.pravatar.cc/150?u=david" }, + { name: "Lisa Johansson", username: "@lisaj", body: "Our team loves writing docs now. The components make it easy to create rich, interactive content.", img: "https://i.pravatar.cc/150?u=lisa" }, + { name: "James Walker", username: "@jwalker", body: "Seriously impressed by the OG thumbnail generation. Every page gets a beautiful social card automatically.", img: "https://i.pravatar.cc/150?u=james" }, +]; + +const firstRow = testimonials.slice(0, Math.ceil(testimonials.length / 2)); +const secondRow = testimonials.slice(Math.ceil(testimonials.length / 2)); +--- + +
+
+

+ Testimonials +

+

+ {title} +

+

+ {description} +

+
+ +
+ +
+ {[...firstRow, ...firstRow, ...firstRow, ...firstRow].map((t) => ( +
+
+ {t.name} +
+
{t.name}
+

{t.username}

+
+
+
{t.body}
+
+ ))} +
+ + +
+ {[...secondRow, ...secondRow, ...secondRow, ...secondRow].map((t) => ( +
+
+ {t.name} +
+
{t.name}
+

{t.username}

+
+
+
{t.body}
+
+ ))} +
+ + +
+
+
+
+ + diff --git a/src/lib/components/ui/background-animation.tsx b/src/lib/components/ui/background-animation.tsx index 3fb70d5..a803564 100644 --- a/src/lib/components/ui/background-animation.tsx +++ b/src/lib/components/ui/background-animation.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from "react"; interface Point { x: number; @@ -7,32 +7,36 @@ interface Point { export function BackgroundAnimation() { const [points, setPoints] = useState(() => - Array(16).fill(0).map(() => ({ - x: Math.random(), - y: Math.random() - })) + Array(16) + .fill(0) + .map(() => ({ + x: Math.random(), + y: Math.random(), + })), ); - const poly = useMemo(() => - points.map(({ x, y }) => `${x * 100}% ${y * 100}%`).join(', '), - [points] + const poly = useMemo( + () => points.map(({ x, y }) => `${x * 100}% ${y * 100}%`).join(", "), + [points], ); const jumpVal = (val: number) => { - return Math.random() > 0.5 ? val + (Math.random() - 0.5) / 2 : Math.random(); + return Math.random() > 0.5 + ? val + (Math.random() - 0.5) / 2 + : Math.random(); }; function jumpPoints() { - setPoints(prevPoints => - prevPoints.map(point => ({ + setPoints((prevPoints) => + prevPoints.map((point) => ({ x: jumpVal(point.x), - y: jumpVal(point.y) - })) + y: jumpVal(point.y), + })), ); } useEffect(() => { - jumpPoints() + jumpPoints(); const timeout = setTimeout(jumpPoints, 2000 + Math.random() * 1000); const interval = setInterval(jumpPoints, 3000 + Math.random() * 1000); @@ -43,14 +47,17 @@ export function BackgroundAnimation() { }, []); return ( -