Compare commits
117
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acfaa4a696 | ||
|
|
6bb81f66f4 | ||
|
|
51b182d01b | ||
|
|
604110c801 | ||
|
|
bbcdeb4caf | ||
|
|
924fd9cc69 | ||
|
|
4e86fe2c4a | ||
|
|
0f070de51a | ||
|
|
aa43d51c42 | ||
|
|
6ee8277205 | ||
|
|
14d910b267 | ||
|
|
1f68655314 | ||
|
|
0450a78478 | ||
|
|
991b0c356e | ||
|
|
be4b79026b | ||
|
|
2fa42ad3aa | ||
|
|
67d593cb73 | ||
|
|
eff68287f2 | ||
|
|
a4c386e438 | ||
|
|
328aa31600 | ||
|
|
fbc33a5ff4 | ||
|
|
a470377d4f | ||
|
|
6447916039 | ||
|
|
1ee2f3a8ac | ||
|
|
d1f3f4ff88 | ||
|
|
81c54c9afc | ||
|
|
5548287c65 | ||
|
|
485c5d5578 | ||
|
|
9af6ba21b9 | ||
|
|
2b67ba4461 | ||
|
|
f9d95448db | ||
|
|
6a27518be1 | ||
|
|
3c85fec634 | ||
|
|
1860f90785 | ||
|
|
28fe0b7e04 | ||
|
|
802244762e | ||
|
|
521487ab10 | ||
|
|
00ee256107 | ||
|
|
9efa1552ee | ||
|
|
b0b9a0d815 | ||
|
|
77f72ec8af | ||
|
|
688a36e6d5 | ||
|
|
f7df74c574 | ||
|
|
0c08a7467f | ||
|
|
046ef92149 | ||
|
|
96b2054ecd | ||
|
|
e1d8cd833d | ||
|
|
95d6341fa7 | ||
|
|
7425a484c2 | ||
|
|
9b5dcdfce5 | ||
|
|
bf97cac2e9 | ||
|
|
9a354ee62a | ||
|
|
2e041bcfb0 | ||
|
|
1894ac7ead | ||
|
|
86f04ed794 | ||
|
|
c884432594 | ||
|
|
bc87e7b7e3 | ||
|
|
34e4beb0d7 | ||
|
|
142788d740 | ||
|
|
11d6c275c8 | ||
|
|
ebf65c247c | ||
|
|
90eb205e85 | ||
|
|
942f87dcac | ||
|
|
9c5a693281 | ||
|
|
f2cfa49150 | ||
|
|
e82dbafd4a | ||
|
|
fd830796e8 | ||
|
|
1a8cf21c88 | ||
|
|
b401a678cb | ||
|
|
1337fae991 | ||
|
|
a7b3880088 | ||
|
|
cd75fd2288 | ||
|
|
a7b85af45a | ||
|
|
eeeaa706a2 | ||
|
|
b8fbab8a18 | ||
|
|
a8b9f8f5db | ||
|
|
8b5e61de66 | ||
|
|
f4dc53d3cf | ||
|
|
5b4d8732e0 | ||
|
|
5114e8ae87 | ||
|
|
c70f26bb0e | ||
|
|
8b2073474d | ||
|
|
8ca6bec943 | ||
|
|
de2861fa78 | ||
|
|
54edbae731 | ||
|
|
940096b2b1 | ||
|
|
0b429f5465 | ||
|
|
2e70319d99 | ||
|
|
dd95694492 | ||
|
|
bb8581a535 | ||
|
|
1117ff1827 | ||
|
|
b9580e2e5f | ||
|
|
59f9d5dbc7 | ||
|
|
b864251f48 | ||
|
|
11c5432955 | ||
|
|
60712f5499 | ||
|
|
9abee89478 | ||
|
|
4090203dc4 | ||
|
|
91ea3f9a70 | ||
|
|
81df3351f5 | ||
|
|
6696ed9b23 | ||
|
|
baf3590186 | ||
|
|
61f4b0447b | ||
|
|
b18c218b4e | ||
|
|
e32fe6ccf3 | ||
|
|
4c76897cac | ||
|
|
5497a5cdc4 | ||
|
|
8caa19b132 | ||
|
|
588139bfa7 | ||
|
|
21a5eb670e | ||
|
|
022a9d96bd | ||
|
|
bb1a482a8d | ||
|
|
2608bf64eb | ||
|
|
04268535a6 | ||
|
|
630e8b9c84 | ||
|
|
ab8671e0fb | ||
|
|
c51fcd5df6 |
@@ -0,0 +1,27 @@
|
|||||||
|
name: Trigger container build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'content/**'
|
||||||
|
- 'app/**'
|
||||||
|
- 'public/**'
|
||||||
|
- 'server/**'
|
||||||
|
- 'nuxt.config.ts'
|
||||||
|
- 'content.config.ts'
|
||||||
|
- 'tsconfig.json'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
trigger:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Dispatch docudjeex-build
|
||||||
|
run: |
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token ${{ secrets.BUILD_REPO_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"ref\":\"main\",\"inputs\":{\"source_sha\":\"${{ github.sha }}\"}}" \
|
||||||
|
"https://git.djeex.fr/api/v1/repos/Djeex/docudjeex-build/actions/workflows/build.yml/dispatches"
|
||||||
+9
-4
@@ -24,9 +24,6 @@ logs
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
# Template
|
|
||||||
template/pnpm-lock.yaml
|
|
||||||
|
|
||||||
# npm pack
|
# npm pack
|
||||||
*.tgz
|
*.tgz
|
||||||
|
|
||||||
@@ -36,4 +33,12 @@ template/pnpm-lock.yaml
|
|||||||
*.0x
|
*.0x
|
||||||
|
|
||||||
#VSC
|
#VSC
|
||||||
.history
|
.history
|
||||||
|
.wrangler
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Scratch/demo files (not part of the site)
|
||||||
|
scratch
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"git": {
|
|
||||||
"commitMessage": "chore(release): v${version}",
|
|
||||||
"tagName": "v${version}"
|
|
||||||
},
|
|
||||||
"github": {
|
|
||||||
"release": true,
|
|
||||||
"releaseName": "v${version}",
|
|
||||||
"web": true
|
|
||||||
},
|
|
||||||
"hooks": {
|
|
||||||
"before:init": ["pnpm lint"]
|
|
||||||
},
|
|
||||||
"plugins": {
|
|
||||||
"@release-it/conventional-changelog": {
|
|
||||||
"preset": {
|
|
||||||
"name": "conventionalcommits"
|
|
||||||
},
|
|
||||||
"infile": "CHANGELOG.md",
|
|
||||||
"header": "# Changelog",
|
|
||||||
"ignoreRecommendedBump": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Nuxt dev/build outputs
|
|
||||||
.output
|
|
||||||
.data
|
|
||||||
.nuxt
|
|
||||||
.nitro
|
|
||||||
.cache
|
|
||||||
dist
|
|
||||||
|
|
||||||
# Node dependencies
|
|
||||||
node_modules
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Misc
|
|
||||||
.DS_Store
|
|
||||||
.fleet
|
|
||||||
.idea
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Local env files
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Template
|
|
||||||
template/pnpm-lock.yaml
|
|
||||||
|
|
||||||
# npm pack
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Temp files
|
|
||||||
.tmp
|
|
||||||
.profile
|
|
||||||
*.0x
|
|
||||||
|
|
||||||
#VSC
|
|
||||||
.history
|
|
||||||
.wrangler
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
title: Getting Started
|
|
||||||
icon: false
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
---
|
|
||||||
title: Introduction
|
|
||||||
description: Welcome to Docus theme documentation.
|
|
||||||
navigation:
|
|
||||||
icon: i-lucide-house
|
|
||||||
seo:
|
|
||||||
title: Introduction
|
|
||||||
description: Discover how to create, manage, and publish documentation
|
|
||||||
effortlessly with Docus.
|
|
||||||
---
|
|
||||||
|
|
||||||
Welcome to **Docus**, a fully integrated documentation solution built with [Nuxt UI Pro](https://ui.nuxt.com/pro).
|
|
||||||
|
|
||||||
## What is Docus?
|
|
||||||
|
|
||||||
Docus is a theme based on the [UI Pro documentation template](https://docs-template.nuxt.dev/). While the visual style comes ready out of the box, your focus should be on writing content using the Markdown and [MDC syntax](https://content.nuxt.com/docs/files/markdown#mdc-syntax) provided by [Nuxt Content](https://content.nuxt.com).
|
|
||||||
|
|
||||||
We use this theme across all our Nuxt module documentations, including:
|
|
||||||
|
|
||||||
::card-group
|
|
||||||
:::card
|
|
||||||
---
|
|
||||||
icon: i-simple-icons-nuxtdotjs
|
|
||||||
target: _blank
|
|
||||||
title: Nuxt Image
|
|
||||||
to: https://image.nuxt.com
|
|
||||||
---
|
|
||||||
The documentation of `@nuxt/image`
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::card
|
|
||||||
---
|
|
||||||
icon: i-simple-icons-nuxtdotjs
|
|
||||||
target: _blank
|
|
||||||
title: Nuxt Supabase
|
|
||||||
to: https://supabase.nuxtjs.org
|
|
||||||
---
|
|
||||||
The documentation of `@nuxt/supabase`
|
|
||||||
:::
|
|
||||||
::
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
This theme includes a range of features designed to improve documentation management:
|
|
||||||
|
|
||||||
- **Powered by** [**Nuxt 3**](https://nuxt.com): Utilizes the latest Nuxt framework for optimal performance.
|
|
||||||
- **Built with** [**Nuxt UI**](https://ui.nuxt.com) **and** [**Nuxt UI Pro**](https://ui.nuxt.com/pro): Integrates a comprehensive suite of UI components.
|
|
||||||
- [**MDC Syntax**](https://content.nuxt.com/usage/markdown) **via** [**Nuxt Content**](https://content.nuxt.com): Supports Markdown with component integration for dynamic content.
|
|
||||||
- [**Nuxt Studio**](https://content.nuxt.com/docs/studio) **Compatible**: Write and edit your content visually. No Markdown knowledge is required!
|
|
||||||
- **Auto-generated Sidebar Navigation**: Automatically generates navigation from content structure.
|
|
||||||
- **Full-Text Search**: Includes built-in search functionality for content discovery.
|
|
||||||
- **Optimized Typography**: Features refined typography for enhanced readability.
|
|
||||||
- **Dark Mode**: Offers dark mode support for user preference.
|
|
||||||
- **Extensive Functionality**: Explore the theme to fully appreciate its capabilities.
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
---
|
|
||||||
title: Installation
|
|
||||||
description: Get started with Docus.
|
|
||||||
navigation:
|
|
||||||
icon: i-lucide-download
|
|
||||||
seo:
|
|
||||||
description: Get started with Docus documentation theme.
|
|
||||||
---
|
|
||||||
|
|
||||||
## Local development
|
|
||||||
|
|
||||||
::steps
|
|
||||||
### Create your docs directory
|
|
||||||
|
|
||||||
Use the `docus` CLI to create a new Docus project in the `docs/` directory:
|
|
||||||
|
|
||||||
```bash [Terminal]
|
|
||||||
npx docus init docs
|
|
||||||
```
|
|
||||||
|
|
||||||
We recommend using the `npm` package manager.
|
|
||||||
|
|
||||||
### Start your docs server in development
|
|
||||||
|
|
||||||
Move to the `docs/` directory and start your docs server in development mode:
|
|
||||||
|
|
||||||
```bash [Terminal]
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
A local preview of your documentation will be available at <http://localhost:4000>
|
|
||||||
|
|
||||||
### Write your documentation
|
|
||||||
|
|
||||||
Head over the [Edition](https://docus.dev/concepts/edition) section to learn how to write your documentation.
|
|
||||||
::
|
|
||||||
|
|
||||||
## Online Edition with [Nuxt Studio](https://content.nuxt.com/studio)
|
|
||||||
|
|
||||||
::prose-steps
|
|
||||||
### Create a new project on [Nuxt Studio](https://nuxt.studio)
|
|
||||||
|
|
||||||
Choose `Start from a template` and select **Docus.** Clone it on your GitHub personal account or any organisation of your choice.
|
|
||||||
|
|
||||||
### Deploy in one click
|
|
||||||
|
|
||||||
Once your project has been created and you're in the project dashboard, navigate to the `Deploy` section, choose the `GitHub Pages` tab and set your [Nuxt UI Pro license](https://ui.nuxt.com/pro/pricing) (`NUXT_UI_PRO_LICENSE` ) in the environment variables then click on the **Deploy** button.
|
|
||||||
|
|
||||||
:::prose-note
|
|
||||||
---
|
|
||||||
to: https://content.nuxt.com/docs/studio/setup#enable-the-full-editing-experience
|
|
||||||
---
|
|
||||||
This is a one click static deployment available with [GitHub Pages](https://docs.github.com/en/pages/getting-started-with-github-pages/creating-a-github-pages-site) but you can also handle deployment yourself and use the `Selfhosted` tab.
|
|
||||||
:::
|
|
||||||
|
|
||||||
### Write your documentation in the editor
|
|
||||||
|
|
||||||
Once the deployment is achieved, you'll be able to display the preview of your documentation. You can browse your content pages to edit them or create new ones.
|
|
||||||
|
|
||||||
:video{controls loop poster="https://res.cloudinary.com/nuxt/video/upload/v1747230893/studio/wzt9zfmdvk7hgmdx3cnt.jpg" src="https://res.cloudinary.com/nuxt/video/upload/v1747230893/studio/wzt9zfmdvk7hgmdx3cnt.mp4"}
|
|
||||||
::
|
|
||||||
|
|
||||||
::prose-tip{to="https://content.nuxt.com/docs/studio/debug"}
|
|
||||||
If you want to try Docus and Nuxt Studio in develoment mode without an UI Pro license, you can check the Nuxt Content documentation for local setup with Nuxt Studio.
|
|
||||||
::
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
---
|
|
||||||
navigation:
|
|
||||||
icon: i-lucide-folder-tree
|
|
||||||
title: Project Structure
|
|
||||||
---
|
|
||||||
|
|
||||||
Docus provides a ready-to-use [documentation website starter](https://github.com/nuxtlabs/docus/tree/.starter).
|
|
||||||
|
|
||||||
This is the minimal directory structure to get an up and running Docus website.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
content/
|
|
||||||
index.md
|
|
||||||
public/
|
|
||||||
favicon.ico
|
|
||||||
package.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### `content/` directory
|
|
||||||
|
|
||||||
This is where you [write pages](https://docus.dev/concepts/edition) in Markdown.
|
|
||||||
|
|
||||||
### `public/` directory
|
|
||||||
|
|
||||||
Files contained within the `public/` directory are served at the root and are not modified by the build process of your documentation. This is where you can locate your medias.
|
|
||||||
|
|
||||||
### `package.json`
|
|
||||||
|
|
||||||
This file contains all the dependencies and scripts for your application. The `package.json` of a Docus application si really minimal and looks like:
|
|
||||||
|
|
||||||
```json [package.json]
|
|
||||||
{
|
|
||||||
"name": "docus-starter",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "docus dev",
|
|
||||||
"build": "docus build"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"docus": "latest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `app.config.ts`
|
|
||||||
|
|
||||||
*This file is not mandatory to start a Docus application.*
|
|
||||||
|
|
||||||
This is where you can [configure Docus](https://docus.dev/concepts/configuration) to fit your branding, handle SEO and adapt links and socials.
|
|
||||||
|
|
||||||
::prose-tip{to="https://docus.dev/concepts/nuxt"}
|
|
||||||
Docus uses a layer system, you can go further and use any feature or file of a classical Nuxt project from `nuxt.config.ts` to `app/components` or `server/` directory.
|
|
||||||
::
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
---
|
|
||||||
title: Web Editor
|
|
||||||
description: Build your documentation using Nuxt Studio web editor
|
|
||||||
navigation:
|
|
||||||
icon: i-lucide-mouse-pointer-2
|
|
||||||
---
|
|
||||||
|
|
||||||
## **Introduction**
|
|
||||||
|
|
||||||
The [Nuxt Studio](https://nuxt.studio) **web editor** is a browser-based visual interface for creating, editing, and reviewing your documentation. It provides a preview experience while keeping your work in sync with your Git repository.
|
|
||||||
|
|
||||||
:video{controls loop src="https://res.cloudinary.com/nuxt/video/upload/v1747230893/studio/wzt9zfmdvk7hgmdx3cnt.mp4"}
|
|
||||||
|
|
||||||
::prose-tip{to="https://content.nuxt.com/studio"}
|
|
||||||
Learn more about Nuxt Studio in the Nuxt Content documentation.
|
|
||||||
::
|
|
||||||
|
|
||||||
## **Web Editor vs. CLI**
|
|
||||||
|
|
||||||
The **web editor** of [Nuxt Studio](https://nuxt.studio) allows you to manage your documentation entirely from your browser. There is no need for local development tools or terminal commands. It’s ideal for maintaining your docs in one centralised place, with an easy tool without any Markdown skills required.
|
|
||||||
|
|
||||||
The **CLI (Command Line Interface)**, on the other hand, is a local tool designed for developers who prefer working in their own IDE.
|
|
||||||
|
|
||||||
::prose-note
|
|
||||||
Both tools are fully integrated with Git, so you can switch between them as needed. Team members can choose whichever method suits their workflow best.
|
|
||||||
::
|
|
||||||
|
|
||||||
## **Two distinct editors**
|
|
||||||
|
|
||||||
Nuxt Studio offers a versatile workspace for both developers and content writers, giving them the freedom to choose between two distinct editors for content creation and management: the **Markdown editor** and the **Visual editor**.
|
|
||||||
|
|
||||||
You can select your favorite editor from the settings page of your project.
|
|
||||||
|
|
||||||
::prose-note
|
|
||||||
Each editor serves its own purpose, some users are used to Markdown edition, while others prefer a non-technical, visual approach. At the end, **Markdown syntax is the final output** for both editors.
|
|
||||||
::
|
|
||||||
|
|
||||||
## **Markdown editor**
|
|
||||||
|
|
||||||
The Markdown editor in Nuxt Studio provides full control over your content, allowing you to write directly you documentation in **Markdown** and integrate Vue components with the [MDC syntax](https://content.nuxt.com/docs/files/markdown#mdc-syntax).
|
|
||||||
|
|
||||||
When your file is saved with the Markdown editor, the content is stored exactly as you've written it, preserving all specific syntax and formatting. This editor is ideal for users comfortable with Markdown who want precise control over the layout and structure of their content.
|
|
||||||
|
|
||||||
## **Visual editor**
|
|
||||||
|
|
||||||
The Nuxt Studio editor is heavily inspired by Notion, well known for its intuitive design and flexibility. Much like a standard text editor, the Studio editor is designed to be familiar and easy to use.
|
|
||||||
|
|
||||||
However, it stands out with its additional features that improve the writing experience:
|
|
||||||
|
|
||||||
### **Toolbar**
|
|
||||||
|
|
||||||
Highlight your text to reveal the toolbar, giving you access to all the standard text editing features provided by the [Markdown syntax](/essentials/markdown-syntax):
|
|
||||||
|
|
||||||
- Title formatting
|
|
||||||
- Bold
|
|
||||||
- Italic
|
|
||||||
- Strike-through
|
|
||||||
- Code
|
|
||||||
- Link
|
|
||||||
- Class
|
|
||||||
- Bullet list
|
|
||||||
- Numerated list
|
|
||||||
|
|
||||||
### **The** `/` **shortcut**
|
|
||||||
|
|
||||||
Simply type `/` anywhere in the editor to access all Studio features.
|
|
||||||
|
|
||||||
#### **Formatting features**
|
|
||||||
|
|
||||||
- Title formatting
|
|
||||||
- Line break
|
|
||||||
- Horizontal rule
|
|
||||||
- Code-block
|
|
||||||
- Paragraph
|
|
||||||
- Bold & italic
|
|
||||||
|
|
||||||
#### **Components**
|
|
||||||
|
|
||||||
One of Studio's standout features is its ability to integrate and customize any complex component directly within the editor.
|
|
||||||
|
|
||||||
In other terms, all [Nuxt UI components](/essentials/components) are usable and can be integrated directly from the editor. An editor can also tweak the component properties, slots and styles.
|
|
||||||
|
|
||||||
::prose-note
|
|
||||||
You can also create custom components and let the user integrate them from the visual editor.
|
|
||||||
::
|
|
||||||
|
|
||||||
Just type `/` to access the list of all the components available.
|
|
||||||
|
|
||||||
#### **Images**
|
|
||||||
|
|
||||||
Using the `/`shortcut, you can quickly insert an image by selecting the `Image` option. A modal will open to let you choose the media you want to insert.
|
|
||||||
|
|
||||||
From the media modal, you can set the **alt attribute** for SEO and accessibility purpose.
|
|
||||||
|
|
||||||
#### **Videos**
|
|
||||||
|
|
||||||
Using the `/` shortcut, you can quickly insert a video by selecting the `Video` choice and filling up the Video URL.
|
|
||||||
|
|
||||||
As soon as a video is added, a tab will automatically open with all the props field **available by default**, for you to fill out the URL and customize your media.
|
|
||||||
|
|
||||||
## **Live Preview**
|
|
||||||
|
|
||||||
Once your documentation is deployed, it provides a live preview feature that lets you instantly see updates to your project.
|
|
||||||
|
|
||||||
We're using your production website to override contents and display the new visual. This is why we need the URL to be set in the **deploy** section.
|
|
||||||
|
|
||||||
When you are editing your website with Studio, the live preview can be displayed on the right part of your screen. You get an instant feedback when typing. It syncs the preview based on your draft updates.
|
|
||||||
|
|
||||||
## **Making Changes**
|
|
||||||
|
|
||||||
To edit your documentation:
|
|
||||||
|
|
||||||
1. **Browse files** using the file explorer.
|
|
||||||
2. **Open a file** by clicking on it.
|
|
||||||
3. **Edit content** in either visual or Markdown mode. All edits are automatically saved as drafts.
|
|
||||||
4. **Preview your changes** to see how they’ll appear when published.
|
|
||||||
|
|
||||||
## **Publishing Changes**
|
|
||||||
|
|
||||||
When you’re ready to publish:
|
|
||||||
|
|
||||||
- Click the **Publish** button in the top-right corner of the editor.
|
|
||||||
- Your changes will be pushed directly to your deployment branch and go live immediately.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
---
|
|
||||||
title: Migration
|
|
||||||
description: " How to migrate your documentation from an existing Markdown
|
|
||||||
solution to Docus"
|
|
||||||
navigation:
|
|
||||||
icon: i-lucide-replace
|
|
||||||
---
|
|
||||||
|
|
||||||
## **Migrating to Docus**
|
|
||||||
|
|
||||||
Already using a Markdown-based solution for your documentation? Whether it’s **Docus v1**, the **Nuxt UI Pro docs template**, or another static site setup, migrating to Docus is simple and straightforward.
|
|
||||||
|
|
||||||
Docus offers a clean and maintainable solution with a single dependency: the Docus library itself. There’s no need to manage multiple .dependencies With everything built-in and maintained together, keeping your documentation up to date is easier than ever.
|
|
||||||
|
|
||||||
To migrate, just move your existing Markdown files into the `content/` directory of the Docus starter.
|
|
||||||
|
|
||||||
From there, you have two scenarios:
|
|
||||||
|
|
||||||
- **If your current docs already use Nuxt Content and the MDC syntax**, make sure the components used in your content exist in Nuxt UI. If any components are missing, you can easily create your own custom ones.
|
|
||||||
- **If you’re using standard Markdown**, you can copy your files as is. Then, enhance your documentation progressively using the [built-in components](https://docus.dev/essentials/components) provided by Nuxt UI.
|
|
||||||
|
|
||||||
Once your content has been moved to the `content/` folder, you can go through the [configuration section](https://docus.dev/concepts/configuration) to easily customize your app.
|
|
||||||
|
|
||||||
Docus is designed to focus on writing content, so if you're already using Markdown, you can easily switch to it.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
title: Essentials
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
---
|
|
||||||
title: Markdown Syntax
|
|
||||||
description: Text, title, and styling in standard markdown.
|
|
||||||
navigation:
|
|
||||||
icon: i-lucide-heading-1
|
|
||||||
---
|
|
||||||
|
|
||||||
## Titles
|
|
||||||
|
|
||||||
Use titles to introduce main sections. They structure your documentation and help users navigate content.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
---
|
|
||||||
class: "[&>div]:*:my-0"
|
|
||||||
---
|
|
||||||
## Titles
|
|
||||||
|
|
||||||
#code
|
|
||||||
```mdc
|
|
||||||
## Titles
|
|
||||||
```
|
|
||||||
::
|
|
||||||
|
|
||||||
### Subtitles
|
|
||||||
|
|
||||||
Use subtitles to divide sections further. They create a more detailed content hierarchy for better readability.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
---
|
|
||||||
class: "[&>div]:*:my-0"
|
|
||||||
---
|
|
||||||
### Subtitles
|
|
||||||
|
|
||||||
#code
|
|
||||||
```mdc
|
|
||||||
### Subtitles
|
|
||||||
```
|
|
||||||
::
|
|
||||||
|
|
||||||
::tip
|
|
||||||
Each title and subtitle creates an anchor and shows up automatically in the table of contents.
|
|
||||||
::
|
|
||||||
|
|
||||||
## Text Formatting
|
|
||||||
|
|
||||||
Docus supports most Markdown formatting options.
|
|
||||||
|
|
||||||
| Style | How to use | Result |
|
|
||||||
| ------ | ------------ | ---------- |
|
|
||||||
| Bold | `**bold**` | **Bold** |
|
|
||||||
| Italic | `*italic*` | *Italic* |
|
|
||||||
| Strike | `~~strike~~` | ~~Strike~~ |
|
|
||||||
|
|
||||||
Combine formatting for richer text styles and visual emphasis.
|
|
||||||
|
|
||||||
| Style | How to use | Result |
|
|
||||||
| ------------- | ------------------- | ----------------- |
|
|
||||||
| Bold Italic | `**_bold italic_**` | ***Bold Italic*** |
|
|
||||||
| Bold Strike | `~~**bold**~~` | ~~**Bold**~~ |
|
|
||||||
| Italic Strike | `~~*italic*~~` | ~~*Italic*~~ |
|
|
||||||
|
|
||||||
## Links
|
|
||||||
|
|
||||||
Links connect different parts of your documentation and external resources, essential for user navigation and providing references.
|
|
||||||
To create a link, wrap the link text in brackets `[]()`.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
---
|
|
||||||
class: "[&>div]:*:my-0"
|
|
||||||
---
|
|
||||||
[Nuxt UI Pro](https://ui.nuxt.com/getting-started/installation/pro/nuxt)
|
|
||||||
|
|
||||||
#code
|
|
||||||
```mdc
|
|
||||||
[Nuxt UI Pro](https://ui.nuxt.com/getting-started/installation/pro/nuxt)
|
|
||||||
```
|
|
||||||
::
|
|
||||||
|
|
||||||
### Internal links
|
|
||||||
|
|
||||||
For linking within your documentation, use root-relative paths like `/getting-started/installation`.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
---
|
|
||||||
class: "[&>div]:*:my-0"
|
|
||||||
---
|
|
||||||
[Installation](/getting-started/installation)
|
|
||||||
|
|
||||||
#code
|
|
||||||
```mdc
|
|
||||||
[Installation](/getting-started/installation)
|
|
||||||
```
|
|
||||||
::
|
|
||||||
|
|
||||||
## Lists
|
|
||||||
|
|
||||||
Organize related items in a structured, readable format. Markdown supports unordered, ordered, and nested lists for various content needs.
|
|
||||||
|
|
||||||
### Unordered
|
|
||||||
|
|
||||||
Use unordered lists for items without a specific sequence. Start each item with a `-` symbol.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
---
|
|
||||||
class: "[&>div]:*:my-0"
|
|
||||||
---
|
|
||||||
- I'm a list item.
|
|
||||||
- I'm another list item.
|
|
||||||
- I'm the last list item.
|
|
||||||
|
|
||||||
#code
|
|
||||||
```mdc
|
|
||||||
- I'm a list item.
|
|
||||||
- I'm another list item.
|
|
||||||
- I'm the last list item.
|
|
||||||
```
|
|
||||||
::
|
|
||||||
|
|
||||||
### Ordered
|
|
||||||
|
|
||||||
Use ordered lists when item order matters, like steps in a process. Start each item with a number.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
---
|
|
||||||
class: "[&>div]:*:my-0"
|
|
||||||
---
|
|
||||||
1. I'm a list item.
|
|
||||||
2. I'm another list item.
|
|
||||||
3. I'm the last list item.
|
|
||||||
|
|
||||||
#code
|
|
||||||
```mdc
|
|
||||||
1. I'm a list item.
|
|
||||||
2. I'm another list item.
|
|
||||||
3. I'm the last list item.
|
|
||||||
```
|
|
||||||
::
|
|
||||||
|
|
||||||
### Nested
|
|
||||||
|
|
||||||
Create hierarchical lists with sub-items for complex structures. Indent sub-items by four spaces for nesting.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
---
|
|
||||||
class: "[&>div]:*:my-0"
|
|
||||||
---
|
|
||||||
- I'm a list item.
|
|
||||||
- I'm a nested list item.
|
|
||||||
- I'm another nested list item.
|
|
||||||
- I'm another list item.
|
|
||||||
|
|
||||||
#code
|
|
||||||
```mdc
|
|
||||||
- I'm a list item.
|
|
||||||
- I'm a nested list item.
|
|
||||||
- I'm another nested list item.
|
|
||||||
- I'm another list item.
|
|
||||||
```
|
|
||||||
::
|
|
||||||
|
|
||||||
## Tables
|
|
||||||
|
|
||||||
Present structured data in rows and columns clearly. Tables are ideal for comparing data or listing properties.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
---
|
|
||||||
class: "[&>div]:*:my-0 [&>div]:*:w-full"
|
|
||||||
---
|
|
||||||
| Prop | Default | Type |
|
|
||||||
| ------- | --------- | -------- |
|
|
||||||
| `name` | | `string` |
|
|
||||||
| `size` | `md` | `string` |
|
|
||||||
| `color` | `neutral` | `string` |
|
|
||||||
|
|
||||||
#code
|
|
||||||
```mdc
|
|
||||||
| Prop | Default | Type |
|
|
||||||
|---------|-----------|--------------------------|
|
|
||||||
| `name` | | `string`{lang="ts-type"} |
|
|
||||||
| `size` | `md` | `string`{lang="ts-type"} |
|
|
||||||
| `color` | `neutral` | `string`{lang="ts-type"} |
|
|
||||||
```
|
|
||||||
::
|
|
||||||
|
|
||||||
## Blockquotes
|
|
||||||
|
|
||||||
Highlight important quotations, citations, or emphasized text. Blockquotes visually distinguish quoted content.
|
|
||||||
|
|
||||||
### Singleline
|
|
||||||
|
|
||||||
Single-line blockquotes are best for short, impactful quotes or citations that fit within a single line. To create a single-line blockquote, add a `>` in front of a paragraph. Ideal for short and impactful quotes.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
---
|
|
||||||
class: "[&>div]:*:my-0"
|
|
||||||
---
|
|
||||||
> Nuxt UI Pro is a collection of Vue components, composables and utils built on top of Nuxt UI, oriented on structure and layout and designed to be used as building blocks for your app.
|
|
||||||
|
|
||||||
#code
|
|
||||||
```mdc
|
|
||||||
> Nuxt UI Pro is a collection of Vue components, composables and utils built on top of Nuxt UI, oriented on structure and layout and designed to be used as building blocks for your app.
|
|
||||||
```
|
|
||||||
::
|
|
||||||
|
|
||||||
### Multiline
|
|
||||||
|
|
||||||
Multi-line blockquotes are suitable for longer quotes or when you need to include multiple paragraphs within a single quotation.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
---
|
|
||||||
class: "[&>div]:*:my-0"
|
|
||||||
---
|
|
||||||
> Nuxt UI Pro is a collection of Vue components, composables and utils built on top of Nuxt UI, oriented on structure and layout and designed to be used as building blocks for your app.
|
|
||||||
>
|
|
||||||
> Create beautiful, responsive, and accessible Vue applications with Nuxt UI Pro.
|
|
||||||
|
|
||||||
#code
|
|
||||||
```mdc
|
|
||||||
> Nuxt UI Pro is a collection of Vue components, composables and utils built on top of Nuxt UI, oriented on structure and layout and designed to be used as building blocks for your app.
|
|
||||||
>
|
|
||||||
> Create beautiful, responsive, and accessible Vue applications with Nuxt UI Pro.
|
|
||||||
```
|
|
||||||
::
|
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
---
|
|
||||||
title: Code Blocks
|
|
||||||
description: Display inline code and code blocks in your documentation.
|
|
||||||
navigation:
|
|
||||||
icon: i-lucide-code-xml
|
|
||||||
---
|
|
||||||
|
|
||||||
## Basic
|
|
||||||
|
|
||||||
### Inline Code
|
|
||||||
|
|
||||||
Use inline code to display code snippets within text paragraphs. It's ideal for referencing code elements directly in sentences.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
---
|
|
||||||
class: "[&>div]:*:my-0"
|
|
||||||
---
|
|
||||||
`inline code`
|
|
||||||
|
|
||||||
#code
|
|
||||||
```mdc
|
|
||||||
`inline code`
|
|
||||||
```
|
|
||||||
::
|
|
||||||
|
|
||||||
### Code Blocks
|
|
||||||
|
|
||||||
Use code blocks to display multi-line code snippets with syntax highlighting. Code blocks are essential for presenting code examples clearly.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
---
|
|
||||||
class: "[&>div]:*:my-0 [&>div]:*:w-full"
|
|
||||||
---
|
|
||||||
```ts
|
|
||||||
export default defineNuxtConfig({
|
|
||||||
modules: ['@nuxt/ui-pro']
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
#code
|
|
||||||
````mdc
|
|
||||||
```ts
|
|
||||||
export default defineNuxtConfig({
|
|
||||||
modules: ['@nuxt/ui-pro']
|
|
||||||
})
|
|
||||||
```
|
|
||||||
````
|
|
||||||
::
|
|
||||||
|
|
||||||
When writing a code-block, you can specify a filename that will be displayed on top of the code block. An icon will be automatically displayed based on the extension or the name.
|
|
||||||
Filenames help users understand the code's location and purpose within a project.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
---
|
|
||||||
class: "[&>div]:*:my-0 [&>div]:*:w-full"
|
|
||||||
---
|
|
||||||
```ts [nuxt.config.ts]
|
|
||||||
export default defineNuxtConfig({
|
|
||||||
modules: ['@nuxt/ui-pro']
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
#code
|
|
||||||
````mdc
|
|
||||||
```ts [nuxt.config.ts]
|
|
||||||
export default defineNuxtConfig({
|
|
||||||
modules: ['@nuxt/ui-pro']
|
|
||||||
})
|
|
||||||
```
|
|
||||||
````
|
|
||||||
::
|
|
||||||
|
|
||||||
Every code-block has a built-in copy button that will copy the code to your clipboard.
|
|
||||||
|
|
||||||
::tip{to="https://ui.nuxt.com/getting-started/icons/nuxt#theme"}
|
|
||||||
Icons are already defined by default, but you can customize them in your `app.config.ts`:
|
|
||||||
|
|
||||||
```ts [app.config.ts]
|
|
||||||
export default defineAppConfig({
|
|
||||||
uiPro: {
|
|
||||||
prose: {
|
|
||||||
codeIcon: {
|
|
||||||
terminal: 'i-ph-terminal-window-duotone'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
::
|
|
||||||
|
|
||||||
## Advanced
|
|
||||||
|
|
||||||
### CodeGroup
|
|
||||||
|
|
||||||
Group code blocks in tabs using `code-group`. `code-group` is perfect for showing code examples in multiple languages or package managers.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
---
|
|
||||||
class: "[&>div]:*:my-0 [&>div]:*:w-full"
|
|
||||||
---
|
|
||||||
:::code-group{.w-full}
|
|
||||||
```bash [pnpm]
|
|
||||||
pnpm add @nuxt/ui-pro@next
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash [yarn]
|
|
||||||
yarn add @nuxt/ui-pro@next
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash [npm]
|
|
||||||
npm install @nuxt/ui-pro@next
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash [bun]
|
|
||||||
bun add @nuxt/ui-pro@next
|
|
||||||
```
|
|
||||||
:::
|
|
||||||
|
|
||||||
#code
|
|
||||||
````mdc
|
|
||||||
:::code-group
|
|
||||||
|
|
||||||
```bash [pnpm]
|
|
||||||
pnpm add @nuxt/ui-pro@next
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash [yarn]
|
|
||||||
yarn add @nuxt/ui-pro@next
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash [npm]
|
|
||||||
npm install @nuxt/ui-pro@next
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash [bun]
|
|
||||||
bun add @nuxt/ui-pro@next
|
|
||||||
```
|
|
||||||
|
|
||||||
::
|
|
||||||
````
|
|
||||||
::
|
|
||||||
|
|
||||||
### CodeTree
|
|
||||||
|
|
||||||
Display code blocks in a file tree view using `code-tree`. `code-tree` is excellent for showcasing project structures and file relationships.
|
|
||||||
|
|
||||||
::code-preview{class="[&>div]:*:my-0 [&>div]:*:w-full"}
|
|
||||||
:::code-tree{default-value="app/app.config.ts"}
|
|
||||||
```ts [nuxt.config.ts]
|
|
||||||
export default defineNuxtConfig({
|
|
||||||
modules: ['@nuxt/ui-pro'],
|
|
||||||
|
|
||||||
future: {
|
|
||||||
compatibilityVersion: 4
|
|
||||||
},
|
|
||||||
|
|
||||||
css: ['~/assets/css/main.css']
|
|
||||||
})
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
```css [app/assets/css/main.css]
|
|
||||||
@import "tailwindcss";
|
|
||||||
@import "@nuxt/ui-pro";
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts [app/app.config.ts]
|
|
||||||
export default defineAppConfig({
|
|
||||||
ui: {
|
|
||||||
colors: {
|
|
||||||
primary: 'sky',
|
|
||||||
colors: 'slate'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
```vue [app/app.vue]
|
|
||||||
<template>
|
|
||||||
<UApp>
|
|
||||||
<NuxtPage />
|
|
||||||
</UApp>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
```json [package.json]
|
|
||||||
{
|
|
||||||
"name": "nuxt-app",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"build": "nuxt build",
|
|
||||||
"dev": "nuxt dev",
|
|
||||||
"generate": "nuxt generate",
|
|
||||||
"preview": "nuxt preview",
|
|
||||||
"postinstall": "nuxt prepare",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"lint:fix": "eslint --fix ."
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@iconify-json/lucide": "^1.2.18",
|
|
||||||
"@nuxt/ui-pro": "3.0.0-alpha.10",
|
|
||||||
"nuxt": "^3.15.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"eslint": "9.20.1",
|
|
||||||
"typescript": "^5.7.2",
|
|
||||||
"vue-tsc": "^2.2.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```json [tsconfig.json]
|
|
||||||
{
|
|
||||||
"extends": "./.nuxt/tsconfig.json"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
````md [README.md]
|
|
||||||
# Nuxt 3 Minimal Starter
|
|
||||||
|
|
||||||
Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
Make sure to install the dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# npm
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn install
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Server
|
|
||||||
|
|
||||||
Start the development server on `http://localhost:3000`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# npm
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm run dev
|
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn dev
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Production
|
|
||||||
|
|
||||||
Build the application for production:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# npm
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm run build
|
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn build
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Locally preview production build:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# npm
|
|
||||||
npm run preview
|
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm run preview
|
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn preview
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun run preview
|
|
||||||
```
|
|
||||||
|
|
||||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
|
||||||
````
|
|
||||||
:::
|
|
||||||
::
|
|
||||||
|
|
||||||
### `CodePreview`
|
|
||||||
|
|
||||||
Use `code-preview` to show code output alongside the code. `code-preview` is ideal for interactive examples and demonstrating code results.
|
|
||||||
Write the code to be previewed in a the `default` slot and the actual code in the `code` slot.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
---
|
|
||||||
class: "[&>div]:*:my-0 [&>div]:*:w-full"
|
|
||||||
label: Preview
|
|
||||||
---
|
|
||||||
:::code-preview
|
|
||||||
---
|
|
||||||
class: "[&>div]:*:my-0"
|
|
||||||
---
|
|
||||||
`inline code`
|
|
||||||
|
|
||||||
#code
|
|
||||||
```mdc
|
|
||||||
`inline code`
|
|
||||||
```
|
|
||||||
:::
|
|
||||||
|
|
||||||
#code
|
|
||||||
````mdc
|
|
||||||
::code-preview
|
|
||||||
`inline code`
|
|
||||||
|
|
||||||
#code
|
|
||||||
```mdc
|
|
||||||
`inline code`
|
|
||||||
```
|
|
||||||
::
|
|
||||||
````
|
|
||||||
::
|
|
||||||
|
|
||||||
### `CodeCollapse`
|
|
||||||
|
|
||||||
Use `code-collapse` for long code blocks to keep pages clean. `code-collapse` allows users to expand code blocks only when needed, improving readability.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
---
|
|
||||||
class: "[&>div]:*:my-0 [&>div]:*:w-full"
|
|
||||||
---
|
|
||||||
:::code-collapse{class="[&>div]:my-0"}
|
|
||||||
```css [main.css]
|
|
||||||
@import "tailwindcss";
|
|
||||||
@import "@nuxt/ui-pro";
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
--font-sans: 'Public Sans', sans-serif;
|
|
||||||
|
|
||||||
--breakpoint-3xl: 1920px;
|
|
||||||
|
|
||||||
--color-green-50: #EFFDF5;
|
|
||||||
--color-green-100: #D9FBE8;
|
|
||||||
--color-green-200: #B3F5D1;
|
|
||||||
--color-green-300: #75EDAE;
|
|
||||||
--color-green-400: #00DC82;
|
|
||||||
--color-green-500: #00C16A;
|
|
||||||
--color-green-600: #00A155;
|
|
||||||
--color-green-700: #007F45;
|
|
||||||
--color-green-800: #016538;
|
|
||||||
--color-green-900: #0A5331;
|
|
||||||
--color-green-950: #052E16;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
:::
|
|
||||||
|
|
||||||
#code
|
|
||||||
````mdc
|
|
||||||
::code-collapse
|
|
||||||
|
|
||||||
```css [main.css]
|
|
||||||
@import "tailwindcss";
|
|
||||||
@import "@nuxt/ui-pro";
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
--font-sans: 'Public Sans', sans-serif;
|
|
||||||
|
|
||||||
--breakpoint-3xl: 1920px;
|
|
||||||
|
|
||||||
--color-green-50: #EFFDF5;
|
|
||||||
--color-green-100: #D9FBE8;
|
|
||||||
--color-green-200: #B3F5D1;
|
|
||||||
--color-green-300: #75EDAE;
|
|
||||||
--color-green-400: #00DC82;
|
|
||||||
--color-green-500: #00C16A;
|
|
||||||
--color-green-600: #00A155;
|
|
||||||
--color-green-700: #007F45;
|
|
||||||
--color-green-800: #016538;
|
|
||||||
--color-green-900: #0A5331;
|
|
||||||
--color-green-950: #052E16;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
::
|
|
||||||
````
|
|
||||||
::
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
---
|
|
||||||
title: Markdown Components
|
|
||||||
description: Use Markdown components to help you structure your content, with
|
|
||||||
the help of Nuxt UI Pro.
|
|
||||||
navigation:
|
|
||||||
icon: i-lucide-component
|
|
||||||
title: Components
|
|
||||||
---
|
|
||||||
|
|
||||||
Prose components are replacements for HTML typography tags. They provide a simple way to customize your UI when using Markdown.
|
|
||||||
|
|
||||||
**Docus and Nuxt UI Pro** provides a set of styled and beautiful prose components to help you write your documentation using the [MDC syntax](https://content.nuxt.com/docs/files/markdown#mdc-syntax).
|
|
||||||
|
|
||||||
::prose-note{to="https://ui.nuxt.com/getting-started"}
|
|
||||||
This page highlights only the prose components best suited for writing documentation. However, you can use **any Nuxt UI or Nuxt UI Pro component** in your Markdown. For the full list of available components, visit the Nuxt UI documentation.
|
|
||||||
::
|
|
||||||
|
|
||||||
### `Accordion`
|
|
||||||
|
|
||||||
Use the `accordion` and `accordion-item` components to display an [Accordion](https://ui.nuxt.com/components/accordion) in your content.
|
|
||||||
|
|
||||||
::tabs
|
|
||||||
:::tabs-item{icon="i-lucide-eye" label="Preview"}
|
|
||||||
::::accordion
|
|
||||||
:::::accordion-item
|
|
||||||
---
|
|
||||||
icon: i-lucide-circle-help
|
|
||||||
label: What is Docus and what are its key features??
|
|
||||||
---
|
|
||||||
Docus is a fully integrated documentation solution built with Nuxt UI Pro. It's a theme based on the UI Pro documentation template that provides a ready-to-use visual. User can focus on content using Markdown and MDC syntax.
|
|
||||||
:::::
|
|
||||||
|
|
||||||
:::::accordion-item
|
|
||||||
---
|
|
||||||
icon: i-lucide-circle-help
|
|
||||||
label: How do I get started with Docus?
|
|
||||||
---
|
|
||||||
The only thing you need to start a Docus project is a `content/` folder. You can have a check at the starter for a quick start.
|
|
||||||
:::::
|
|
||||||
|
|
||||||
:::::accordion-item{icon="i-lucide-circle-help" label="What is Nuxt UI Pro?"}
|
|
||||||
Nuxt UI Pro is a collection of premium Vue components, composables and utils built on top of [Nuxt UI](https://ui.nuxt.com/). Nuxt UI Pro is free in development, but you need a license to use it in production.
|
|
||||||
:::::
|
|
||||||
::::
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::tabs-item{icon="i-lucide-code" label="Code"}
|
|
||||||
```mdc
|
|
||||||
::accordion
|
|
||||||
|
|
||||||
:::accordion-item{label="What is Docus and what are its key features??" icon="i-lucide-circle-help"}
|
|
||||||
Docus is a fully integrated documentation solution built with Nuxt UI Pro. It's a theme based on the UI Pro documentation template that provides a ready-to-use visual. User can focus on content using Markdown and MDC syntax.
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::accordion-item{label="How do I get started with Docus?" icon="i-lucide-circle-help"}
|
|
||||||
The only thing you need to start a Docus project is a `content/` folder. You can have a check at the starter for a quick start.
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::accordion-item{label="What is Nuxt UI Pro?" icon="i-lucide-circle-help"}
|
|
||||||
Nuxt UI Pro is a collection of premium Vue components, composables and utils built on top of [Nuxt UI](https://ui.nuxt.com/). Nuxt UI Pro is free in development, but you need a license to use it in production.
|
|
||||||
:::
|
|
||||||
::
|
|
||||||
```
|
|
||||||
:::
|
|
||||||
::
|
|
||||||
|
|
||||||
### `Badge`
|
|
||||||
|
|
||||||
Use markdown in the default slot of the `badge` component to display a [Badge](https://ui.nuxt.com/components/badge) in your content.
|
|
||||||
|
|
||||||
::tabs
|
|
||||||
:::tabs-item{.my-5 icon="i-lucide-eye" label="Preview"}
|
|
||||||
::::badge
|
|
||||||
**v3.0.0**
|
|
||||||
::::
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::tabs-item{icon="i-lucide-code" label="Code"}
|
|
||||||
```mdc
|
|
||||||
::badge
|
|
||||||
**v3.0.0**
|
|
||||||
::
|
|
||||||
```
|
|
||||||
:::
|
|
||||||
::
|
|
||||||
|
|
||||||
### `Callout`
|
|
||||||
|
|
||||||
Use markdown in the default slot of the `callout` component to add eye-catching context to your content.
|
|
||||||
|
|
||||||
Use the `icon` and `color` props to customize it. You can also pass any property from the [`<NuxtLink>`](https://nuxt.com/docs/api/components/nuxt-link) component.
|
|
||||||
|
|
||||||
You can also use the `note`, `tip`, `warning` and `caution` shortcuts with pre-defined icons and colors.
|
|
||||||
|
|
||||||
::tabs
|
|
||||||
:::tabs-item{.my-5 icon="i-lucide-eye" label="Preview"}
|
|
||||||
::::div{.flex.flex-col.gap-4.w-full}
|
|
||||||
:::::note{.w-full.my-0}
|
|
||||||
Here's some additional information for you.
|
|
||||||
:::::
|
|
||||||
|
|
||||||
:::::tip{.w-full.my-0}
|
|
||||||
Here's a helpful suggestion.
|
|
||||||
:::::
|
|
||||||
|
|
||||||
:::::warning{.w-full.my-0}
|
|
||||||
Be careful with this action as it might have unexpected results.
|
|
||||||
:::::
|
|
||||||
|
|
||||||
:::::caution{.w-full.my-0}
|
|
||||||
This action cannot be undone.
|
|
||||||
:::::
|
|
||||||
::::
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::tabs-item{icon="i-lucide-code" label="Code"}
|
|
||||||
```mdc
|
|
||||||
::note
|
|
||||||
Here's some additional information.
|
|
||||||
::
|
|
||||||
|
|
||||||
::tip
|
|
||||||
Here's a helpful suggestion.
|
|
||||||
::
|
|
||||||
|
|
||||||
::warning
|
|
||||||
Be careful with this action as it might have unexpected results.
|
|
||||||
::
|
|
||||||
|
|
||||||
::caution
|
|
||||||
This action cannot be undone.
|
|
||||||
::
|
|
||||||
```
|
|
||||||
:::
|
|
||||||
::
|
|
||||||
|
|
||||||
### `Card` and `CardGroup`
|
|
||||||
|
|
||||||
Use markdown in the default slot of the `card` component to highlight your content.
|
|
||||||
|
|
||||||
Use the `title`, `icon` and `color` props to customize it. You can also pass any property from the [`<NuxtLink>`](https://nuxt.com/docs/api/components/nuxt-link).
|
|
||||||
|
|
||||||
Wrap your `card` components with the `card-group` component to group them together in a grid layout.
|
|
||||||
|
|
||||||
::tabs
|
|
||||||
:::tabs-item{.my-5 icon="i-lucide-eye" label="Preview"}
|
|
||||||
::::card-group{.w-full.my-0}
|
|
||||||
:::::card
|
|
||||||
---
|
|
||||||
icon: i-simple-icons-github
|
|
||||||
target: _blank
|
|
||||||
title: Dashboard
|
|
||||||
to: https://github.com/nuxt-ui-pro/dashboard
|
|
||||||
---
|
|
||||||
A dashboard with multi-column layout.
|
|
||||||
:::::
|
|
||||||
|
|
||||||
:::::card
|
|
||||||
---
|
|
||||||
icon: i-simple-icons-github
|
|
||||||
target: _blank
|
|
||||||
title: SaaS
|
|
||||||
to: https://github.com/nuxt-ui-pro/saas
|
|
||||||
---
|
|
||||||
A template with landing, pricing, docs and blog.
|
|
||||||
:::::
|
|
||||||
|
|
||||||
:::::card
|
|
||||||
---
|
|
||||||
icon: i-simple-icons-github
|
|
||||||
target: _blank
|
|
||||||
title: Docs
|
|
||||||
to: https://github.com/nuxt-ui-pro/docs
|
|
||||||
---
|
|
||||||
A documentation with `@nuxt/content`.
|
|
||||||
:::::
|
|
||||||
|
|
||||||
:::::card
|
|
||||||
---
|
|
||||||
icon: i-simple-icons-github
|
|
||||||
target: _blank
|
|
||||||
title: Landing
|
|
||||||
to: https://github.com/nuxt-ui-pro/landing
|
|
||||||
---
|
|
||||||
A landing page you can use as starting point.
|
|
||||||
:::::
|
|
||||||
::::
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::tabs-item{.my-5 icon="i-lucide-eye" label="Preview"}
|
|
||||||
```mdc
|
|
||||||
:::card-group
|
|
||||||
|
|
||||||
::card
|
|
||||||
---
|
|
||||||
title: Dashboard
|
|
||||||
icon: i-simple-icons-github
|
|
||||||
to: https://github.com/nuxt-ui-pro/dashboard
|
|
||||||
target: _blank
|
|
||||||
---
|
|
||||||
A dashboard with multi-column layout.
|
|
||||||
::
|
|
||||||
|
|
||||||
::card
|
|
||||||
---
|
|
||||||
title: SaaS
|
|
||||||
icon: i-simple-icons-github
|
|
||||||
to: https://github.com/nuxt-ui-pro/saas
|
|
||||||
target: _blank
|
|
||||||
---
|
|
||||||
A template with landing, pricing, docs and blog.
|
|
||||||
::
|
|
||||||
|
|
||||||
::card
|
|
||||||
---
|
|
||||||
title: Docs
|
|
||||||
icon: i-simple-icons-github
|
|
||||||
to: https://github.com/nuxt-ui-pro/docs
|
|
||||||
target: _blank
|
|
||||||
---
|
|
||||||
A documentation with `@nuxt/content`.
|
|
||||||
::
|
|
||||||
|
|
||||||
::card
|
|
||||||
---
|
|
||||||
title: Landing
|
|
||||||
icon: i-simple-icons-github
|
|
||||||
to: https://github.com/nuxt-ui-pro/landing
|
|
||||||
target: _blank
|
|
||||||
---
|
|
||||||
A landing page you can use as starting point.
|
|
||||||
::
|
|
||||||
|
|
||||||
:::
|
|
||||||
```
|
|
||||||
:::
|
|
||||||
::
|
|
||||||
|
|
||||||
### `Collapsible`
|
|
||||||
|
|
||||||
Wrap your content with the `collapsible` component to display a [Collapsible](https://ui.nuxt.com/components/collapsible) in your content.
|
|
||||||
|
|
||||||
::tabs
|
|
||||||
:::tabs-item{.my-5 icon="i-lucide-eye" label="Preview"}
|
|
||||||
::::collapsible
|
|
||||||
| Prop | Default | Type |
|
|
||||||
| ------- | --------- | -------- |
|
|
||||||
| `name` | | `string` |
|
|
||||||
| `size` | `md` | `string` |
|
|
||||||
| `color` | `neutral` | `string` |
|
|
||||||
::::
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::tabs-item{icon="i-lucide-code" label="Code"}
|
|
||||||
```mdc
|
|
||||||
::collapsible
|
|
||||||
|
|
||||||
| Prop | Default | Type |
|
|
||||||
|---------|-----------|--------------------------|
|
|
||||||
| `name` | | `string`{lang="ts-type"} |
|
|
||||||
| `size` | `md` | `string`{lang="ts-type"} |
|
|
||||||
| `color` | `neutral` | `string`{lang="ts-type"} |
|
|
||||||
|
|
||||||
::
|
|
||||||
```
|
|
||||||
:::
|
|
||||||
::
|
|
||||||
|
|
||||||
### `Field` and `FieldGroup`
|
|
||||||
|
|
||||||
A `field`is a prop or parameter to display in your content. You can group them by `field-group` in a list.
|
|
||||||
|
|
||||||
::tabs
|
|
||||||
:::tabs-item{.my-5 icon="i-lucide-eye" label="Preview"}
|
|
||||||
::::field-group{.my-0}
|
|
||||||
:::::field{name="analytics" type="boolean"}
|
|
||||||
Default to `false` - Enables analytics for your project (coming soon).
|
|
||||||
:::::
|
|
||||||
|
|
||||||
:::::field{name="blob" type="boolean"}
|
|
||||||
Default to `false` - Enables blob storage to store static assets, such as images, videos and more.
|
|
||||||
:::::
|
|
||||||
|
|
||||||
:::::field{name="cache" type="boolean"}
|
|
||||||
Default to `false` - Enables cache storage to cache your server route responses or functions using Nitro's `cachedEventHandler` and `cachedFunction`
|
|
||||||
:::::
|
|
||||||
|
|
||||||
:::::field{name="database" type="boolean"}
|
|
||||||
Default to `false` - Enables SQL database to store your application's data.
|
|
||||||
:::::
|
|
||||||
::::
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::tabs-item{icon="i-lucide-code" label="Code"}
|
|
||||||
```mdc
|
|
||||||
::field-group
|
|
||||||
::field{name="analytics" type="boolean"}
|
|
||||||
Default to `false` - Enables analytics for your project (coming soon).
|
|
||||||
::
|
|
||||||
|
|
||||||
::field{name="blob" type="boolean"}
|
|
||||||
Default to `false` - Enables blob storage to store static assets, such as images, videos and more.
|
|
||||||
::
|
|
||||||
|
|
||||||
::field{name="cache" type="boolean"}
|
|
||||||
Default to `false` - Enables cache storage to cache your server route responses or functions using Nitro's `cachedEventHandler` and `cachedFunction`
|
|
||||||
::
|
|
||||||
|
|
||||||
::field{name="database" type="boolean"}
|
|
||||||
Default to `false` - Enables SQL database to store your application's data.
|
|
||||||
::
|
|
||||||
::
|
|
||||||
```
|
|
||||||
:::
|
|
||||||
::
|
|
||||||
|
|
||||||
### `Icon`
|
|
||||||
|
|
||||||
Use the `icon` component to display an [Icon](https://ui.nuxt.com/components/icon) in your content.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
:icon{name="i-simple-icons-nuxtdotjs"}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#code
|
|
||||||
```mdc
|
|
||||||
:icon{name="i-simple-icons-nuxtdotjs"}
|
|
||||||
```
|
|
||||||
::
|
|
||||||
|
|
||||||
### `Kbd`
|
|
||||||
|
|
||||||
Use the `kbd` component to display a [Kbd](https://ui.nuxt.com/components/kbd) in your content.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
#code
|
|
||||||
```mdc
|
|
||||||
:kbd{value="meta"} :kbd{value="K"}
|
|
||||||
```
|
|
||||||
::
|
|
||||||
|
|
||||||
### `Tabs`
|
|
||||||
|
|
||||||
Use the `tabs` and `tabs-item` components to display [Tabs](https://ui.nuxt.com/components/tabs) in your content.
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||
:::tabs{.w-full}
|
|
||||||
::::tabs-item{icon="i-lucide-code" label="Code"}
|
|
||||||
```mdc
|
|
||||||
::callout
|
|
||||||
Lorem velit voluptate ex reprehenderit ullamco et culpa.
|
|
||||||
::
|
|
||||||
```
|
|
||||||
::::
|
|
||||||
|
|
||||||
::::tabs-item{icon="i-lucide-eye" label="Preview"}
|
|
||||||
:::::callout
|
|
||||||
Lorem velit voluptate ex reprehenderit ullamco et culpa.
|
|
||||||
:::::
|
|
||||||
::::
|
|
||||||
:::
|
|
||||||
|
|
||||||
#code
|
|
||||||
````mdc
|
|
||||||
::tabs{.w-full}
|
|
||||||
:::tabs-item{icon="i-lucide-code" label="Code"}
|
|
||||||
```mdc
|
|
||||||
::::callout
|
|
||||||
Lorem velit voluptate ex reprehenderit ullamco et culpa.
|
|
||||||
::::
|
|
||||||
```
|
|
||||||
::::
|
|
||||||
|
|
||||||
:::tabs-item{icon="i-lucide-eye" label="Preview"}
|
|
||||||
:::::callout
|
|
||||||
Lorem velit voluptate ex reprehenderit ullamco et culpa.
|
|
||||||
:::::
|
|
||||||
:::
|
|
||||||
::
|
|
||||||
````
|
|
||||||
::
|
|
||||||
|
|
||||||
### `Steps`
|
|
||||||
|
|
||||||
Wrap your headings with the Steps component to display a list of steps.
|
|
||||||
|
|
||||||
Use the `level` prop to define which heading will be used for the steps.
|
|
||||||
|
|
||||||
::tabs
|
|
||||||
:::tabs-item{.my-5 icon="i-lucide-eye" label="Preview"}
|
|
||||||
::::steps{level="4"}
|
|
||||||
#### Start a fresh new project
|
|
||||||
|
|
||||||
```bash [Terminal]
|
|
||||||
npx nuxi init -t github:nuxt-ui-pro/docus
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Run docus CLI to run your dev server
|
|
||||||
|
|
||||||
```bash [Terminal]
|
|
||||||
docus dev
|
|
||||||
```
|
|
||||||
::::
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::tabs-item{icon="i-lucide-code" label="Code"}
|
|
||||||
````mdc
|
|
||||||
::steps{level="4"}
|
|
||||||
#### Start a fresh new project
|
|
||||||
|
|
||||||
```bash [Terminal]
|
|
||||||
npx nuxi init -t github:nuxt-ui-pro/docus
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Run docus CLI to run your dev server
|
|
||||||
|
|
||||||
```bash [Terminal]
|
|
||||||
docus dev
|
|
||||||
```
|
|
||||||
::
|
|
||||||
````
|
|
||||||
:::
|
|
||||||
::
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
---
|
|
||||||
title: Images and Embeds
|
|
||||||
description: Add image, video, and other HTML elements
|
|
||||||
navigation:
|
|
||||||
icon: i-lucide-image
|
|
||||||
seo:
|
|
||||||
description: Add image, video, and other HTML elements using Docus theme
|
|
||||||
---
|
|
||||||
|
|
||||||
## Markdown
|
|
||||||
|
|
||||||
Display images or videos using standard Markdown syntax.
|
|
||||||
|
|
||||||
### Images
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||

|
|
||||||
|
|
||||||
#code
|
|
||||||
```mdc
|
|
||||||

|
|
||||||
```
|
|
||||||
::
|
|
||||||
|
|
||||||
Or with your local images
|
|
||||||
|
|
||||||
::code-preview
|
|
||||||

|
|
||||||
|
|
||||||
#code
|
|
||||||
```mdc
|
|
||||||

|
|
||||||
```
|
|
||||||
::
|
|
||||||
|
|
||||||
::note{to="https://image.nuxt.com/"}
|
|
||||||
Docus will use `<NuxtImg>` component under the hood instead of the native `img` tag.
|
|
||||||
::
|
|
||||||
|
|
||||||
### Videos
|
|
||||||
|
|
||||||
::prose-code-preview
|
|
||||||
:video{autoplay controls loop src="https://res.cloudinary.com/dcrl8q2g3/video/upload/v1745404403/landing_od8epr.mp4"}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#code
|
|
||||||
```mdc
|
|
||||||
:video{autoplay controls loop src="https://res.cloudinary.com/dcrl8q2g3/video/upload/v1745404403/landing_od8epr.mp4"}
|
|
||||||
```
|
|
||||||
::
|
|
||||||
|
|
||||||
###
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
---
|
|
||||||
seo:
|
|
||||||
title: Write beautiful docs with Markdown
|
|
||||||
description: Ship fast, flexible, and SEO-optimized documentation with beautiful
|
|
||||||
design out of the box. Docus brings together the best of the Nuxt ecosystem.
|
|
||||||
Powered by Nuxt UI Pro.
|
|
||||||
---
|
|
||||||
|
|
||||||
::u-page-hero
|
|
||||||
#title
|
|
||||||
Write beautiful docs with Markdown
|
|
||||||
|
|
||||||
#description
|
|
||||||
Ship fast, flexible, and SEO-optimized documentation with beautiful design out of the box.
|
|
||||||
|
|
||||||
Docus brings the best of the Nuxt ecosystem into one CLI.
|
|
||||||
|
|
||||||
#links
|
|
||||||
:::u-button
|
|
||||||
---
|
|
||||||
color: neutral
|
|
||||||
size: xl
|
|
||||||
to: /getting-started/installation
|
|
||||||
trailing-icon: i-lucide-arrow-right
|
|
||||||
---
|
|
||||||
Get started
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::u-button
|
|
||||||
---
|
|
||||||
color: neutral
|
|
||||||
icon: simple-icons-github
|
|
||||||
size: xl
|
|
||||||
to: https://github.com/nuxt-ui-pro/docus
|
|
||||||
variant: outline
|
|
||||||
---
|
|
||||||
Star on GitHub
|
|
||||||
:::
|
|
||||||
::
|
|
||||||
|
|
||||||
::u-page-section
|
|
||||||
#title
|
|
||||||
Shipped with many features
|
|
||||||
|
|
||||||
#features
|
|
||||||
:::u-page-feature
|
|
||||||
---
|
|
||||||
icon: i-simple-icons-nuxt
|
|
||||||
target: _blank
|
|
||||||
to: https://nuxt.com
|
|
||||||
---
|
|
||||||
#title
|
|
||||||
Built with [Nuxt 3]{.text-primary}
|
|
||||||
|
|
||||||
#description
|
|
||||||
Optimized by the most famous Vue framework. Docus gives you everything you need to build fast, performant, and SEO-friendly websites.
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::u-page-feature
|
|
||||||
---
|
|
||||||
icon: i-simple-icons-nuxt
|
|
||||||
target: _blank
|
|
||||||
to: https://ui.nuxt.com/
|
|
||||||
---
|
|
||||||
#title
|
|
||||||
Powered by [Nuxt UI Pro]{.text-primary}
|
|
||||||
|
|
||||||
#description
|
|
||||||
Beautiful out of the box, minimal by design but highly customizable. Docus leverages Nuxt UI Pro to give you the best docs writing experience with zero boilerplate, just focus on your content.
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::u-page-feature
|
|
||||||
---
|
|
||||||
icon: i-simple-icons-nuxt
|
|
||||||
target: _blank
|
|
||||||
to: https://content.nuxt.com
|
|
||||||
---
|
|
||||||
#title
|
|
||||||
Enhanced Markdown syntax by [Nuxt Content]{.text-primary}
|
|
||||||
|
|
||||||
#description
|
|
||||||
The only thing you need to take care about is writing your content. Write your pages in Markdown and extend with MDC syntax to embed Nuxt UI or custom Vue components. Structure, routing, and rendering are handled for you.
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::u-page-feature
|
|
||||||
---
|
|
||||||
icon: i-simple-icons-nuxt
|
|
||||||
target: _blank
|
|
||||||
to: https://nuxt.com/docs/guide/directory-structure/app-config
|
|
||||||
---
|
|
||||||
#title
|
|
||||||
Customize with [Nuxt App Config]{.text-primary}
|
|
||||||
|
|
||||||
#description
|
|
||||||
Update colors, social links, header logos and component styles globally using the `app.config.ts`, no direct code modifications required.
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::u-page-feature
|
|
||||||
---
|
|
||||||
icon: i-simple-icons-nuxt
|
|
||||||
target: _blank
|
|
||||||
to: https://content.nuxt.com/studio
|
|
||||||
---
|
|
||||||
#title
|
|
||||||
Collaborate on [Nuxt Studio]{.text-primary}
|
|
||||||
|
|
||||||
#description
|
|
||||||
Write and manage your content visually, with zero Markdown knowledge required. Let your non technical colleagues collaborate on the documentation and integrate Vue components without code skills.
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::u-page-feature
|
|
||||||
---
|
|
||||||
icon: i-simple-icons-nuxt
|
|
||||||
target: _blank
|
|
||||||
to: https://ui.nuxt.com/components/content-search
|
|
||||||
---
|
|
||||||
#title
|
|
||||||
Built-in navigation and [full-text search]{.text-primary}
|
|
||||||
|
|
||||||
#description
|
|
||||||
Only focus on ordering your content, Docus handles the search modal and auto-generates the side navigation for you.
|
|
||||||
:::
|
|
||||||
::
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "docus-starter",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "docus dev",
|
|
||||||
"build": "docus build"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"docus": "latest",
|
|
||||||
"better-sqlite3": "^11.10.0",
|
|
||||||
"nuxt": "^3.17.6"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 364 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 234 KiB |
-139
@@ -1,139 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
## [3.0.5](https://github.com/nuxtlabs/docus/compare/v3.0.4...v3.0.5) (2025-07-01)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **app:** add `bash` & `diff` highlight langs ([55e0fa0](https://github.com/nuxtlabs/docus/commit/55e0fa0408e5d0656fa36cb49554668dbc288082))
|
|
||||||
* **app:** handle `github.rootDir` app config ([0698787](https://github.com/nuxtlabs/docus/commit/06987870962d6c3b604f35126c10018388930c37))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **app:** add `highlight` on content toc ([a511b50](https://github.com/nuxtlabs/docus/commit/a511b508cd2140f86ba7e045244cf59b68f84c68))
|
|
||||||
* **app:** allow content navigation variant override ([ccc1340](https://github.com/nuxtlabs/docus/commit/ccc1340faa0da10ac8e17d909739b987b337fcd4))
|
|
||||||
* **app:** display page links in header ([9acc755](https://github.com/nuxtlabs/docus/commit/9acc75565f1f92013371319a2506edbba7dd415c))
|
|
||||||
* **app:** import from `@nuxt/kit` ([d8dbee4](https://github.com/nuxtlabs/docus/commit/d8dbee4c804754b94ff3abc9e0d0225f5112688a))
|
|
||||||
* **app:** improve app config type ([246c16a](https://github.com/nuxtlabs/docus/commit/246c16a984e1e837c19bdd22c439bb6fb5bbf813))
|
|
||||||
* **app:** use `useClipboard` to copy page ([a8cd48b](https://github.com/nuxtlabs/docus/commit/a8cd48b063679b1c58142842ef857abf15fc8630))
|
|
||||||
|
|
||||||
## [3.0.4](https://github.com/nuxtlabs/docus/compare/v3.0.3...v3.0.4) (2025-06-24)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **prerender:** redirect issue with static deployment ([5f8fbb3](https://github.com/nuxtlabs/docus/commit/5f8fbb32c5cba8479b2562495d0fb7c49291c6de))
|
|
||||||
|
|
||||||
## [3.0.3](https://github.com/nuxtlabs/docus/compare/v3.0.2...v3.0.3) (2025-06-20)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **nav:** handle nav for docs folder ([65a477a](https://github.com/nuxtlabs/docus/commit/65a477a0974ced0cae7aed6d5fd498ec4e7e0687))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **landing:** conditionally add prose ([1931668](https://github.com/nuxtlabs/docus/commit/19316680c2c2035d7d72b4628d2caa901b3a01a6))
|
|
||||||
* **landing:** put back prose ([73edf2a](https://github.com/nuxtlabs/docus/commit/73edf2a417802b5a366af17d17961f4e9a900564))
|
|
||||||
* **prerender:** add mardown raw content ([e35b7aa](https://github.com/nuxtlabs/docus/commit/e35b7aaab954f69b7b8edd67d92a37ba6678c9d4))
|
|
||||||
|
|
||||||
## [3.0.2](https://github.com/nuxtlabs/docus/compare/v3.0.1...v3.0.2) (2025-06-17)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **llms:** enable full by default ([677078f](https://github.com/nuxtlabs/docus/commit/677078f0d1e432d7b25e876374e36eeb2796d5f2))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **setup:** docs layer ([d407155](https://github.com/nuxtlabs/docus/commit/d40715593adecf5e8421e100e897687a28a56e39))
|
|
||||||
* **starter:** prerender issues ([2facbea](https://github.com/nuxtlabs/docus/commit/2facbeaa3c8c9287c2048c754602063912fe5a49))
|
|
||||||
|
|
||||||
## [3.0.1](https://github.com/nuxtlabs/docus/compare/v3.0.0...v3.0.1) (2025-06-17)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **configs:** handle llms and site default configs in a module ([c642df9](https://github.com/nuxtlabs/docus/commit/c642df95c0a3a8b98eddaa33f00b5b1187eeaba8))
|
|
||||||
* improve async data key for SEO ([2de5ffe](https://github.com/nuxtlabs/docus/commit/2de5ffe22ccfc9fb46c802d0fbc77f4a764f78a5))
|
|
||||||
* **llms:** missing deps to enable full ([6d354ce](https://github.com/nuxtlabs/docus/commit/6d354ceafa7792880f50487d2ad392172df10d87))
|
|
||||||
* **setup:** define default app config in module ([b250a1b](https://github.com/nuxtlabs/docus/commit/b250a1b677c6cf1bf68794615c86599864ce9fd5))
|
|
||||||
|
|
||||||
## [3.0.0](https://github.com/nuxtlabs/docus/compare/v3.0.0-alpha.3...v3.0.0) (2025-06-13)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **llms:** enable full ([65374af](https://github.com/nuxtlabs/docus/commit/65374af2bc44c42cb35fa66055bd65d092dcd32e))
|
|
||||||
|
|
||||||
## [3.0.0-alpha.3](https://github.com/nuxtlabs/docus/compare/v3.0.0-alpha.2...v3.0.0-alpha.3) (2025-06-12)
|
|
||||||
|
|
||||||
## [3.0.0-alpha.2](https://github.com/nuxtlabs/docus/compare/v3.0.0-alpha.1...v3.0.0-alpha.2) (2025-06-12)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **ci:** nightly ([26b92a7](https://github.com/nuxtlabs/docus/commit/26b92a71a2abd1e0216f6d7433edfde696c25264))
|
|
||||||
* **deps:** add brace-expansion as optimized deps ([32a5589](https://github.com/nuxtlabs/docus/commit/32a5589d0abaec0a4566778fa154e1eee28c014a))
|
|
||||||
* **deps:** remove brace-expansion optimization ([355ac39](https://github.com/nuxtlabs/docus/commit/355ac39d60674aec4e99234a6e73502db00ce4a8))
|
|
||||||
|
|
||||||
## [3.0.0-alpha.1](https://github.com/nuxtlabs/docus/compare/v3.0.0-alpha.0...v3.0.0-alpha.1) (2025-06-12)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **deps:** refine optimizeDeps of `@nuxt/content` ([#1080](https://github.com/nuxtlabs/docus/issues/1080)) ([ba0f6ef](https://github.com/nuxtlabs/docus/commit/ba0f6effa28b25135719746da17991453ffd678d))
|
|
||||||
|
|
||||||
## [3.0.0-alpha.0](https://github.com/nuxtlabs/docus/compare/v2.0.0-alpha.1...v3.0.0-alpha.0) (2025-06-12)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **docs:** use docus instead of @larbish/docus ([0dee9ec](https://github.com/nuxtlabs/docus/commit/0dee9ec484f4f097c68f2236cb2f927dcbd1db30))
|
|
||||||
|
|
||||||
## [2.0.0-alpha.1](https://github.com/nuxtlabs/docus/compare/v2.0.0-alpha.0...v2.0.0-alpha.1) (2025-06-10)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **cli:** init command ([cfa6290](https://github.com/nuxtlabs/docus/commit/cfa6290f5aa38da852dad57780a277c502df3daf))
|
|
||||||
|
|
||||||
## [2.0.0-alpha.0](https://github.com/nuxtlabs/docus/compare/v2.0.0...v2.0.0-alpha.0) (2025-06-10)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* animate the menu toggle icon ([1cb0273](https://github.com/nuxtlabs/docus/commit/1cb0273d68e8b28b76c22f07cb40ca74c8034189))
|
|
||||||
* **app:** build command ([cb505bb](https://github.com/nuxtlabs/docus/commit/cb505bbfffc50654a4634846ff39510401c93bf2))
|
|
||||||
* **app:** compat nuxt version 4 ([594ac08](https://github.com/nuxtlabs/docus/commit/594ac0834738effb6752f3b3efc25d1dd9f0b3d7))
|
|
||||||
* **app:** init docus v2 ([bc3a9d1](https://github.com/nuxtlabs/docus/commit/bc3a9d101052fb787e562744d5d9b3d87799c24b))
|
|
||||||
* **app:** rename DocsHeaderRight to DocsPageHeaderLinks ([82a2ca8](https://github.com/nuxtlabs/docus/commit/82a2ca87aefdf446c62564010c6839b76a57de89))
|
|
||||||
* **ci:** publish nightly ([4753cd4](https://github.com/nuxtlabs/docus/commit/4753cd450f8f6741572911169ea21c88e1f75915))
|
|
||||||
* **components:** AppHeaderBody ([ae5ecc8](https://github.com/nuxtlabs/docus/commit/ae5ecc86d00918c9fb35f235bc2b72c322932f9d))
|
|
||||||
* **components:** customizable app header and docs asides ([4ca262b](https://github.com/nuxtlabs/docus/commit/4ca262b247f01ad4b8041bf106886f3d506525fa))
|
|
||||||
* **config:** handle app.config.ts ([739cbb1](https://github.com/nuxtlabs/docus/commit/739cbb151a5d8ff510662d1ee534a827e05cb21f))
|
|
||||||
* **docs:** docs header right ([843527c](https://github.com/nuxtlabs/docus/commit/843527cb6f909fd9cf5492b014b659d45490f3ef))
|
|
||||||
* **docs:** links from app config from docs aside right bottom ([28f521d](https://github.com/nuxtlabs/docus/commit/28f521dd51aa4ce5b7354416bd3bf626f9f79cd0))
|
|
||||||
* **docs:** trigger nightly ([4493e33](https://github.com/nuxtlabs/docus/commit/4493e33d6cce899d4eacb4f0191cbfe40b6554d9))
|
|
||||||
* improvements ([b1af212](https://github.com/nuxtlabs/docus/commit/b1af212900712223673617749eecb227378cb3e3))
|
|
||||||
* **llms:** integrate nuxt llms by default ([3f060d8](https://github.com/nuxtlabs/docus/commit/3f060d85bca006e8cea412144fdfda7eec481d1f))
|
|
||||||
* **seo:** og images landing ([a81f07b](https://github.com/nuxtlabs/docus/commit/a81f07b49ee21b6bb1e944178f596065ce8b0ff2))
|
|
||||||
* **seo:** site name and title template ([19fb325](https://github.com/nuxtlabs/docus/commit/19fb32542036ff943bc1ad532ce182d9fe036a5b))
|
|
||||||
* **social:** update og image ([87f87e4](https://github.com/nuxtlabs/docus/commit/87f87e4cb2905267feb2bd66fe8c744d7ace53af))
|
|
||||||
* **starter:** update ([6ddff7f](https://github.com/nuxtlabs/docus/commit/6ddff7fd3909c746c86ac6a82b6bbc350c3e987e))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* add docs dir only if not found as layer ([05fdaf3](https://github.com/nuxtlabs/docus/commit/05fdaf3a87edf1b9470918259e7792b91a82d1a1))
|
|
||||||
* **app:** config ([af15911](https://github.com/nuxtlabs/docus/commit/af15911b054c9d7c3c22902f4d44860da3510f12))
|
|
||||||
* **cli:** layers ([370740c](https://github.com/nuxtlabs/docus/commit/370740c4231d147bac5c5f5f90702fc9f0b3a74e))
|
|
||||||
* **cli:** rename to docus ([ef17013](https://github.com/nuxtlabs/docus/commit/ef1701359be87390ceae4b064970269f4bd206b3))
|
|
||||||
* **cli:** update init cmd ([b902db7](https://github.com/nuxtlabs/docus/commit/b902db7ce6293c778577747905718e62e1e4d4cd))
|
|
||||||
* **config:** update toc links schema ([5ce2c70](https://github.com/nuxtlabs/docus/commit/5ce2c70aa7c52be341c9484d5fd10427c8320d09))
|
|
||||||
* **docs:** copy page ([63f1088](https://github.com/nuxtlabs/docus/commit/63f1088a7b4efe0cf12213df899554cb8e820a86))
|
|
||||||
* **docs:** safari copy to clipboard ([4761858](https://github.com/nuxtlabs/docus/commit/47618586a94169e9e4f75158ffd2e62539735f01))
|
|
||||||
* **git:** fetch info ([98dae4b](https://github.com/nuxtlabs/docus/commit/98dae4bf59829313cd630f7bf4eaffb6003cbe95))
|
|
||||||
* **git:** vercel branch name variable ([966a1b9](https://github.com/nuxtlabs/docus/commit/966a1b9369957e76019efdc3dc0c48c8d3c99a07))
|
|
||||||
* **icon:** use iconify provider ([f41113c](https://github.com/nuxtlabs/docus/commit/f41113c1f767db3f26830070db245fcd542caa5e))
|
|
||||||
* **landing:** neutral and primary in iframe command menu ([e16717d](https://github.com/nuxtlabs/docus/commit/e16717df0ae90cea71bf9c83eb8056c4a3d59202))
|
|
||||||
* **package:** add repository ([cebd917](https://github.com/nuxtlabs/docus/commit/cebd91740a797b7c4f94dd360b335a289f18e2e6))
|
|
||||||
* **package:** dev without rebuild ([67fad9e](https://github.com/nuxtlabs/docus/commit/67fad9ec773caeac517d737dfc8370b1302d4de5))
|
|
||||||
* **package:** set pnpm version ([b96e34b](https://github.com/nuxtlabs/docus/commit/b96e34b6cfb6a070667e812925c5058585710169))
|
|
||||||
* **pages:** possibility to set gh edit url ([85a01e7](https://github.com/nuxtlabs/docus/commit/85a01e79817c53142472c49a4e1db684c71c7b3e))
|
|
||||||
* **schema:** toc ([00b90d8](https://github.com/nuxtlabs/docus/commit/00b90d89fe16db9ee92894e8bb1e797674c9cd93))
|
|
||||||
* **seo:** default title and description ([0321956](https://github.com/nuxtlabs/docus/commit/03219562b9fa02eed1ee1bbf4cbab31092028911))
|
|
||||||
* **seo:** site name and url ([afc59b6](https://github.com/nuxtlabs/docus/commit/afc59b678287caf53b39ac69d65165a67a4840b8))
|
|
||||||
* **seo:** use seo key instead of site ([68bece7](https://github.com/nuxtlabs/docus/commit/68bece7bdbb9b9ef7d9ed7408a37ffb8445a0453))
|
|
||||||
* **seo:** use site title ([9e9df32](https://github.com/nuxtlabs/docus/commit/9e9df3264efec4fd6f4cf9843894c05f3f77ce15))
|
|
||||||
* **setup:** default header title ([1a4ee1d](https://github.com/nuxtlabs/docus/commit/1a4ee1d80f00d673b5b58048c28d673e977b0204))
|
|
||||||
* **setup:** infer URL ([105e1ca](https://github.com/nuxtlabs/docus/commit/105e1ca4ac7b33fd6589cc533c75bed561c06ca4))
|
|
||||||
* source app config ([abc5c35](https://github.com/nuxtlabs/docus/commit/abc5c355daa47665c064a19834455c1e57c5799c))
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Customizations over base Docus
|
||||||
|
|
||||||
|
This project starts from the `docus` i18n starter template (`extends: ['docus']` in `nuxt.config.ts`, Docus v5.x on Nuxt ^4.4.8). This file tracks everything added or changed on top of that base, and *why*, so a future contributor doesn't have to diff `node_modules/docus` to find out.
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
- **`better-sqlite3`** — Nuxt Content v3 stores all parsed markdown content in a local SQLite database (`.data/content/contents.sqlite`) using its own DB layer (`db0`) rather than reading files at request time. `db0` needs an actual SQLite driver to talk to that file, and lists `better-sqlite3` as a *peer* dependency (alongside alternatives like `sqlite3` or `@libsql/client`) — peer dependencies aren't auto-installed by npm, so without declaring it explicitly, `@nuxt/content` has no driver to write to and the local content database silently fails to build.
|
||||||
|
- **`@nuxtjs/i18n`** module added explicitly in `nuxt.config.ts`. The starter ships an i18n-*shaped* content structure (`content/en/`, `content/fr/`) out of the box, but that's just a folder convention — nothing routes `/fr/...` URLs, switches locales, or auto-detects the browser's language unless the module itself is registered.
|
||||||
|
|
||||||
|
## `nuxt.config.ts`
|
||||||
|
|
||||||
|
- **Git-based page contributors.** `getContributors()` runs `git log --format=%an --follow -- <file>` for each markdown file and dedupes the author list, injected into the page's content via the `content:file:afterParse` hook. This was chosen over the Gitea/GitHub API because it needs no access token, no network call, and no rate limiting — the info is already in the checkout. The trade-off: CI must do a **full** (non-shallow) `git checkout`, otherwise `git log` only sees one commit per file and every page shows just its most recent author instead of everyone who ever touched it.
|
||||||
|
- **`@nuxt/image` dir is always an absolute path.** An earlier revision branched this on `nuxi dev` vs a production build, passing the plain relative string `'public/'` in production on the theory that an absolute path there broke the `/_ipx` route's SVG `Content-Type`. In production that relative path doesn't reliably resolve to the project root at the moment the prerender crawler actually requests an `/_ipx/*` URL. The real-world result was every single image on the site 404ing (`IPX_FILE_NOT_FOUND`) in a from-scratch production build, not a content-type quirk on some of them. Always resolving the absolute path (`fileURLToPath(new URL('./public', import.meta.url))`) fixed it in both modes; since the whole site is prerendered to static files anyway, each image's correct extension is what actually decides its serving content-type, not this setting.
|
||||||
|
- **`icon.serverBundle.collections`.** Every icon collection referenced dynamically (built from a variable/string at runtime rather than written as a literal `i-xxx` somewhere) has to be listed here explicitly, or Nuxt Icon's static usage scanner never finds it and falls back to a live `api.iconify.design` request at prerender time, which times out (`[Icon] loading icon X timed out`) wherever outbound network access is restricted, exactly as it was on the actual deployment host. Two places build icon names this way: `app/app.config.ts`'s `ui.prose.codeIcon` map (`simple-icons`, `lucide`) and `app/components/content/FileTreeNode.vue`'s per-extension file-type icons (`vscode-icons`). Add any new collection here the moment a new dynamic icon source is introduced. Check it's actually installed first with `ls node_modules/@iconify-json/`.
|
||||||
|
- **`components:` array: keep `'~/components'` as the first entry.** Passing a `components` array to Nuxt *replaces* its default `~/components` auto-scan instead of adding to it. Every local override in `app/components/` (`AppHeaderCenter`, `DocsAsideLeftTop`, etc., all documented below) was silently shadowed by the docus layer's originals the moment a custom array was added without also re-listing the default scan. The override files were still there, just never picked up. The second entry, `{ path: '~/components/prose', pathPrefix: false, global: true }`, exists because `ProseNote`/`ProseTip`/`ProseWarning`/`ProseCaution` are *only* ever resolved dynamically by name from Nuxt Content's MDC tag map (`note` → `ProseNote`, etc.), and nothing statically writes `<ProseNote>` in a template, so Vite's production build tree-shook all four out of both the client and server bundles entirely, and every admonition on the live site rendered as a raw, unstyled `<ProseNote icon to="...">` tag instead of the actual callout. `nuxt dev` never surfaces either of these: it doesn't tree-shake, and it resolves components on demand regardless of the scan config.
|
||||||
|
- **Custom icon collection.** `icon.customCollections` registers a `brand` prefix pointing at `app/assets/brand-icons/`, so logos for the user's other projects (Instameex, Lumeex) can be referenced from content as `i-brand-instameex` etc., exactly like any Iconify icon — without needing to publish them to an actual Iconify icon set first.
|
||||||
|
- **Markdown highlight.** Forces the `github-dark` Shiki theme for *both* the light and dark slots, because the site never actually offers a light mode (see `docus.colorMode: 'dark'` below) — maintaining two highlight themes for a mode nobody sees would just be dead config. The extra languages (`nginx, properties, php, toml, console, sh, yaml`) were added because the tutorial content includes config-file snippets and terminal output in all of these syntaxes, and none of them are in Shiki's minimal default bundle for Nuxt Content.
|
||||||
|
- **`darkreader-lock` meta tag.** The Dark Reader browser extension rewrites elements' inline `style` attributes on the client, after Nuxt has already server-rendered them — so any component using an inline `style` (like the cyan "·" separator spans) ends up with mismatched HTML between server and client, and Vue logs a hydration-mismatch warning on every page load for any visitor running that extension. This meta tag is Dark Reader's own opt-out signal, telling the extension to leave the page alone instead of trying to work around the mismatch after the fact.
|
||||||
|
- **301 redirects (`routeRules`).** The old site (pre-rewrite, on the separate `docudjeex` repo's `main`/`french` branches) served English at the site root with no locale prefix (`/serveex/introduction/`) and French under `/fr/` with French-language slugs (`/fr/serveex/coeur/installation/`). This rewrite moved every URL under an explicit `/en/`/`/fr/` prefix with English-based slugs on both, which would otherwise break every external link, bookmark, and search-engine ranking built up on the old URLs. All 72 mappings use `statusCode: 301` explicitly, since Nitro's default redirect status is 307 (temporary), which search engines don't treat as "please re-index this at the new URL" the way a 301 (permanent) does. French pages whose old slug already matches the new one (`dozzle`, `immich`, and similar words that are spelled the same in both languages) are deliberately absent, since a rule there would redirect the page to itself. See the warning callout above this section: none of these 72 rules produce a real 301 on the actual production host, only a `location =` block in that host's nginx config does.
|
||||||
|
- **`site.trailingSlash: true`.** The site builds as a static export (`nuxt build`, deployed as static files on a web server) and the production host 301-redirects a bare `path` request to `path/` (verified against `docu.djeex.fr`), so canonical/og:url/sitemap URLs need to already carry the trailing slash — otherwise the canonical tag points at the very URL the server redirects away from, a loop that keeps the page out of search results. This is documented, official behavior for the wider Nuxt SEO ecosystem (`nuxtseo.com`'s "Trailing Slashes" guide), but Docus doesn't depend on `nuxt-seo-utils` for its canonical/og:url logic — it hand-rolls its own in `useSeo.ts` via a plain `joinURL(site.url, route.path)` that never checks this setting. That gap is why the items below exist alongside it.
|
||||||
|
- **`nitro.prerender.autoSubfolderIndex: true`.** Docus sets this to `false` in its own `nuxt.config.ts`, which writes every route as `path.html` instead of `path/index.html` — the exact opposite of what the trailing-slash setup above needs, since the host would then redirect `/path` to `/path/` and find no directory there. Restoring the Nitro default puts the files back where the advertised URLs actually point.
|
||||||
|
- **`nitro.prerender.routes: ['/', '/robots.txt']`.** Docus's `nitro:config` hook seeds one prerender route per locale (`/en`, `/fr`) and `/sitemap.xml`, but never `/robots.txt`. It exists as a server route yet was never written to the static output, so it 404s on a static host and loses the `Sitemap:` line that points crawlers at the sitemap. `/` is listed too, though it turns out this doesn't actually matter: Nitro's crawler always visits `/` on its own regardless (it's the crawl's entry point), confirmed by removing it from this array and finding it prerendered anyway. `@nuxtjs/i18n`'s `detectBrowserLanguage` never actually runs on this static host: `/` is baked as a static `<meta http-equiv="refresh">` stub whose target is a fixed string decided once at build time (`defaultLocale: 'en'`), not read per visitor, and Nitro bakes *any* route carrying a `redirect` routeRule the same way, with no real HTTP status. The real 301 for `/` lives in the production host's nginx config instead, see the warning callout above.
|
||||||
|
- **`experimental.defaults.nuxtLink.trailingSlash: 'append'`.** The native Nuxt-core (not `@nuxtjs/i18n`'s own, separate `trailingSlash` option — that one only affects `switchLocalePath()` and double-appends the slash on hreflang alternate links) way to make every `<NuxtLink>` href, including the ones i18n's `switchLocalePath` builds for hreflang tags, resolve with a trailing slash already, matching both `site.trailingSlash` and the directory-style files on disk.
|
||||||
|
|
||||||
|
> **Do not add a global trailing-slash redirect middleware here.** An earlier revision had `app/middleware/trailing-slash.global.ts` 301-redirecting bare paths to their slash form. It broke the production build outright: Nitro's prerender crawler seeds on `/en` and `/fr`, the middleware turned both into redirect responses, and since Nitro extracts no links from a redirect the crawl stopped immediately — 31 routes and 22 HTML files instead of 557 and 146, with every content page missing. The host already performs that redirect server-side, so the middleware bought nothing.
|
||||||
|
|
||||||
|
> **A `routeRules` redirect can't produce a real HTTP redirect on this deployment.** The production host serves this site as plain static files (nginx `root` + `try_files`, no `proxy_pass` to a running Nuxt/Nitro process at all). Any route with a `redirect` rule still gets crawled and prerendered like any other page, but since a static file can't carry a custom HTTP status, Nitro bakes it as a client-side `<meta http-equiv="refresh">` stub instead. That's invisible to anything that doesn't execute the page (search-engine link-equity, most link-preview tools, `curl`). This was tried for `/` (redirecting to `/en/`) and reverted after confirming the real 301 only appears when running `node .output/server/index.mjs` directly, never through the actual production nginx config. The 72 legacy-URL redirects in `routeRules` below have the exact same limitation: they were made real 301s by adding matching `location = /path { return 301 ...; }` blocks directly in the site's nginx config, outside this repo entirely, not by anything in `nuxt.config.ts`. If this project ever moves to a host that runs the Nitro server itself instead of serving `.output/public` as static files, all of this becomes unnecessary and `routeRules` redirects will work as real 301s on their own.
|
||||||
|
|
||||||
|
## `server/routes/sitemap.xml.ts`
|
||||||
|
|
||||||
|
Overrides Docus's own `sitemap.xml` route (`node_modules/docus/server/routes/sitemap.xml.ts`), for two reasons:
|
||||||
|
|
||||||
|
- Docus's version resolves the site URL via `inferSiteURL()`, which only reads deployment-platform env vars (Vercel/Netlify/Cloudflare Pages, or `NUXT_PUBLIC_SITE_URL`/`NUXT_SITE_URL`) — never the `site.url` set in this project's `nuxt.config.ts`. In `nuxt dev` none of those env vars exist, so every `<loc>` came out as a bare relative path instead of an absolute URL, which is invalid per the sitemap spec.
|
||||||
|
- Even where that env var happens to be set, Docus's version builds each `<loc>` with plain string concatenation and has no concept of `site.trailingSlash` at all, so it could never match the trailing-slash canonical/og:url above.
|
||||||
|
|
||||||
|
This override is otherwise a straight copy of Docus's route, with the URL-building swapped for `createSitePathResolver()` (from `nuxt-site-config`), which resolves from the same `site` config as canonical/og:url and honors `trailingSlash` correctly.
|
||||||
|
|
||||||
|
## `content.config.ts`
|
||||||
|
|
||||||
|
Nuxt Content validates every page's frontmatter against a Zod schema per collection, and **silently drops any key that isn't declared in that schema** — it doesn't error, the field just isn't there at render time. This file reimplements docus's own `createDocsSchema()` (not something the `docus` package actually exports, so it has to be copied rather than imported) and extends it with the custom frontmatter toggles the page template relies on:
|
||||||
|
|
||||||
|
- `hideHeader` — skip the title/description block on a page (used for pages that want a custom hero instead of the standard header).
|
||||||
|
- `hideCopyPage` — hide the "Copy page" button group (for pages where "copy as markdown for an LLM" doesn't make sense).
|
||||||
|
- `hideToc` — hide the right-hand table of contents (for short pages where a TOC would be mostly empty space).
|
||||||
|
- `contributors` — the array populated by the `getContributors()` hook above; without this line in the schema, the hook's output would be computed and then thrown away.
|
||||||
|
|
||||||
|
This was a real bug during development: `hideHeader`/`hideCopyPage` did nothing at all until this schema was extended, because the fields were being stripped before the page component ever saw them.
|
||||||
|
|
||||||
|
## `app/app.config.ts`
|
||||||
|
|
||||||
|
The old production site (`docu.djeex.fr`) has an established visual identity that a "generic Nuxt UI theme" migration would have lost. These overrides were measured directly against the live old site (colors picked from its actual computed styles, not eyeballed) so the new stack keeps the same look rather than just being *a* documentation theme:
|
||||||
|
|
||||||
|
- `docus.colorMode: 'dark'` — the old site never had a light mode either; hard-locking it here removes the need for the toggle UI and light-theme variants entirely, rather than half-supporting a mode nobody uses.
|
||||||
|
- `ui.colors`: primary `cyan`, neutral `zinc` — the site's brand accent color and its neutral gray scale.
|
||||||
|
- `ui.prose.card` / `ui.prose.pre` / `ui.header` / `ui.contentSearchButton` / `ui.contentSurround` / `ui.kbd`: exact background/border hex values (a shared `rgba(12,13,12,0.8)` translucent-dark family, e.g. `#121110` borders) matching the old site's card, code-block, header, search button, and prev/next-link chrome, since Nuxt UI's defaults use a different neutral scale that didn't match.
|
||||||
|
- `ui.prose.callout.compoundVariants`: exact colors for all four admonition severities (info/success/warning/error), overriding Nuxt UI's default callout palette so `::note`, `::tip`, `::warning`, `::caution` render in the same colors the old site's `::alert` boxes used, rather than Nuxt UI's stock blue/green/amber/red.
|
||||||
|
- `toc.bottom.links` / `toc.bottom.title` — no component override needed for this one: Docus's own `DocsAsideRightBottom.vue` already reads `appConfig.toc?.bottom?.links` and renders them via `UPageLinks` under the right-hand table of contents, it's just never set by default. This surfaces the same "other projects" links (git.djeex.fr, Lumeex, Instameex) shown on the landing page's "Other dumb things" section, at the bottom of every doc page's TOC too, instead of only being visible from the homepage.
|
||||||
|
|
||||||
|
## Custom / overridden components (`app/components/`)
|
||||||
|
|
||||||
|
Nuxt's convention is that a file at `app/components/<any-subfolder>/<ExactComponentName>.vue` overrides a layer's (here, docus's) auto-registered component of the same name — no explicit registration needed, just matching the filename. Each one below was diffed against the actual stock file in `node_modules/docus` to confirm it's a real, deliberate change and not an accidental untouched copy:
|
||||||
|
|
||||||
|
- **`app/AppHeader.vue`** — added a Gitea social icon link alongside the stock GitHub link. The project's canonical repository lives on the user's self-hosted Gitea instance; GitHub is only a mirror, so a GitHub-only link would point visitors to the secondary copy.
|
||||||
|
- **`app/AppHeaderCenter.vue`** — the most heavily rewritten component. Stock Docus sizes the header's nav menu to the header's own container width, but this site's actual docs pages use a narrower, off-center content column (a two-level 10-column grid: an outer sidebar column plus an inner article/TOC split) — so the stock menu didn't visually line up under the content it was supposed to sit above. This override renders the nav as an absolutely-positioned overlay that replicates that exact two-level grid, so it lines up with the real article column instead of the header's own slot. Also fixes a real bug found during development: `pointer-events-auto` was originally applied to the full-width wrapper div, which silently blocked clicks on the logo and the right-side icons (search, color mode, socials) everywhere *except* the homepage (a different code path with an empty nav). It's now scoped to only the innermost column div that actually contains clickable content.
|
||||||
|
- **`app/AppHeaderBottom.vue`** — emptied to a no-op `<div />`. Once navigation moved into `AppHeaderCenter` above, the stock second nav row would have shown the same links twice and wasted vertical space in the header.
|
||||||
|
- **`docs/DocsAsideLeftBody.vue`** — the left doc-tree sidebar is now collapsible and closed by default (stock: always fully expanded, not collapsible). With this site's number of nested sections, a fully-expanded tree was one very long scrollable list on every page load; collapsed-by-default lets a visitor see the top-level structure first and open only the section they need.
|
||||||
|
- **`docs/DocsAsideLeftTop.vue`** — added a full-width search button above the sidebar for the header-based subnav mode (stock rendered nothing there in that mode, only in the "aside" subnav mode). Without it, visitors on pages using header-mode subnav had no visible way to open search from the sidebar area at all.
|
||||||
|
- **`docs/DocsPageHeaderLinks.vue`** — gave the "Copy page" button group the same translucent-dark card styling used everywhere else on the site. Purely cosmetic: the stock Nuxt UI button styling didn't match the rest of the page chrome and stood out as an unstyled default.
|
||||||
|
- **`prose/ProseNote.vue`, `ProseTip.vue`, `ProseWarning.vue`, `ProseCaution.vue`** (new files, no stock equivalent to override against — these are thin wrappers around Nuxt UI's own `Callout.vue`). Nuxt UI's admonition icon is normally set once, globally, per icon slot — there's no built-in way to omit it on just one specific admonition without changing it for every admonition of that type site-wide. These wrappers read an optional `icon` prop so a single instance can hide its icon (`::note{icon=""}`) when the emoji or leading text already conveys the same meaning, while every other `::note` on the site keeps its default icon.
|
||||||
|
- **`content/Ellipsis.vue`** (new; no Docus or Nuxt UI equivalent exists at all). The old site had a decorative blurred gradient glow behind section headers, and reproducing the content 1:1 meant this cosmetic effect needed *some* markdown-usable component to exist, since neither Docus nor Nuxt UI ships anything similar. Registered as the inline MDC component `:ellipsis{left= width= top= blur= zIndex=}`, used across content wherever the old site had that effect.
|
||||||
|
- **`OgImage/Docs.takumi.vue`** — overrides Docus's default `og:image` template used for every doc page's social-preview image. Stock Docus renders it on a generic `bg-neutral-950` with a plain white corner flare, in whatever font the takumi renderer defaults to; this swaps in the site's actual near-black background (`#0B0A0A`, matching `app.css`), a blurred oval reproducing the exact colors and diagonal gradient of the site's own `:ellipsis` component instead of the white flare, **Roboto** as the font (the site itself renders in the browser's own `system-ui`, which can't be embedded server-side since it resolves to a different, non-redistributable font per OS — Roboto was picked as Android's system font, the single most common one), and the site's own logo (bottom-left) in place of the plain site-name text. Two non-obvious takumi rendering gotchas found in the process: an injected SVG's XML prolog and comments render as literal visible text instead of being silently ignored like a browser's `innerHTML` would, and a `<style>` block's CSS class rules aren't resolved at all (paths fell back to default black fill) — both needed stripping/inlining by hand in `fetchLogoSvg()` before the SVG string reaches `v-html`. `content/en/index.md` and `content/fr/index.md` skip this template entirely via the `seo.ogImage` frontmatter key (Docus's `landing.vue` checks for it and falls back to a fixed `/img/social.png` instead of generating one), since the homepage's own hero doesn't fit this per-doc-page layout.
|
||||||
|
- **`content/FileTree.vue` + `content/FileTreeNode.vue`** (new; no Docus or Nuxt UI equivalent exists at all). Every install guide used to show its folder layout as a plain ASCII-art code fence (`└──`/`├──`); this renders the same information as an actual tree with per-entry folder/file icons instead, reusing the exact filename/extension icon lookup `CodeIcon.vue` already does for labeled code fences, so a `.env` or `.conf` gets the same icon here as in a fence header. Registered as the container component `::file-tree`, fed through a YAML props block (`remark-mdc`'s `---\n...\n---` syntax) rather than a nested markdown list, since the data (name, whether it's a folder, its children) doesn't map cleanly onto list semantics otherwise. A trailing `/` on a plain string marks an otherwise-childless folder (a mapping key is unambiguously a folder already); a trailing `" # comment"` on either form renders as a dimmed, italic aside, matching a real code comment without being one (an actual unquoted YAML `#` would just be stripped by the parser before the component ever saw it). The header doubles as a collapse toggle (`collapsed` prop sets the initial state only), and clicking any row copies that entry's full path to the clipboard.
|
||||||
|
|
||||||
|
## Page-level features (`app/pages/[[lang]]/[...slug].vue`)
|
||||||
|
|
||||||
|
This catch-all page isn't a docus override (docus doesn't ship one to override — this project defines its own), but it layers frontmatter-driven behavior on top of stock Nuxt Content rendering:
|
||||||
|
|
||||||
|
- `hideHeader` / `hideCopyPage` / `hideToc` — read the three frontmatter toggles declared in `content.config.ts` above and conditionally skip rendering each block.
|
||||||
|
- **Contributors + history block.** Below the "Edit this page" / "Report an issue" links, renders "Contributor(s): <names>" from the `contributors` frontmatter field (populated by the git-log hook), with the names linking to that specific file's Gitea commit history. The goal is to give credit to everyone who's worked on a page — not just whoever last edited it — and let a reader jump straight to the full history of a page without leaving the site or knowing the underlying file path.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT (see `LICENSE`), same as the Docus theme this project is built on.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Functional changelog: site redesign (September 2026)
|
||||||
|
|
||||||
|
This document lists every change a visitor would actually notice between the old version of docu.djeex.fr and the new one. It does not cover technical implementation details, only what changed in the content and in using the site.
|
||||||
|
|
||||||
|
## New articles
|
||||||
|
|
||||||
|
- **"Linux tips for dummies" section** (under General): three new beginner-friendly articles on the Linux terminal.
|
||||||
|
- *CLI basics*: how a Linux command is built, with worked examples, plus a cheat-sheet table explaining where each common command's name comes from (`cd` for *change directory*, `ls` for *list*, and so on).
|
||||||
|
- *Filesystem*: Debian's folder and partition layout, and good practices around it.
|
||||||
|
- *Handy tools*: installing and using `btop`, `duf`, `ncdu`, `tldr`, `lazydocker`, and `ufw` (firewall).
|
||||||
|
- **Jellyfin**: new media server article, now the default recommendation in place of Plex (Plex is still documented, see below).
|
||||||
|
- **TinyAuth**: new article on this lightweight forward-auth proxy, for putting a login page in front of an app.
|
||||||
|
- **Pocket ID**: new article on this self-hosted OIDC provider, letting you log in with a passkey instead of a password.
|
||||||
|
- **File Browser Quantum**: new article on this modernized fork of File Browser, replacing the original as the recommended choice (see below).
|
||||||
|
- **Forgejo**: new article on this self-hosted Git platform, now the default recommendation in place of Gitea (Gitea is still documented, see below).
|
||||||
|
- **Arcane**: new article on this more advanced Docker management UI than Dockge, with support for multiple remote hosts and OIDC login.
|
||||||
|
- **"Confirm before deleting" tip**: new article documenting a Bash function that asks for confirmation before any `sudo`-run `rm`, to help avoid accidental deletions.
|
||||||
|
- **Section summary pages**: the General, Nonsense, and Recycled sections each now have a landing page listing their contents.
|
||||||
|
|
||||||
|
## Replaced or archived articles
|
||||||
|
|
||||||
|
Some apps are no longer the top recommendation, but their article stays available for anyone already using them or who prefers that alternative:
|
||||||
|
|
||||||
|
- **Plex** has been replaced by **Jellyfin** as the recommended choice. The Plex article is kept under a new "Alternatives" section, along with its related qBittorrent and Servarr variants.
|
||||||
|
- **Gitea** has been replaced by **Forgejo** as the recommended choice, following Gitea's governance change to a for-profit company. The Gitea article remains available under "Alternatives."
|
||||||
|
- **File Browser** has been replaced by **File Browser Quantum**. Unlike the two cases above, this one comes with an active warning rather than just being an alternative: the original project accumulated several serious security vulnerabilities (including one allowing full admin account takeover) and was officially abandoned by its maintainers in September 2026. The article is now marked deprecated with an explicit warning not to install it.
|
||||||
|
- **Overseerr** has been replaced by its successor **Seerr** in the automation (Servarr) article, following the merger of the Overseerr and Jellyseerr projects.
|
||||||
|
|
||||||
|
## Navigation reorganization
|
||||||
|
|
||||||
|
- **WireGuard** moved from the "Security" section to "Core": it's now treated as a basic infrastructure building block rather than an optional security tool.
|
||||||
|
- **Authentik** moved to a new **"Advanced"** section, alongside Arcane, for more complex setups aimed at users already comfortable with self-hosting.
|
||||||
|
- The "Security" section now only contains Cloudflare, TinyAuth, and Pocket ID.
|
||||||
|
- Deprecated or replaced articles (Plex, the old File Browser, Gitea, the old WireGuard setup) are now grouped under a new **"Recycled"** section, itself split into "Deprecated" (to avoid) and "Alternatives" (valid choices, just not the default recommendation).
|
||||||
|
|
||||||
|
## Content rewritten or substantially expanded
|
||||||
|
|
||||||
|
- **Debian installation**: heavily expanded article.
|
||||||
|
- Explains how to enable Wake-on-LAN and automatic restart after a power outage, directly in the BIOS.
|
||||||
|
- SSH connection instructions now split by operating system (macOS, Windows, Linux), with the exact commands for each.
|
||||||
|
- A note on temporarily re-enabling SSH password login when connecting a new machine to the server.
|
||||||
|
- A brand new section on waking the server up remotely (Wake-on-LAN from outside the local network), with a concrete port-forwarding example.
|
||||||
|
- A new section on keeping the system up to date, laid out step by step, plus a one-line command tip for fully automatic updates.
|
||||||
|
- A clear prerequisite added at the top: being comfortable with basic terminal commands, linking to the new "CLI basics" article.
|
||||||
|
- **Docker**: the introduction has been fully rewritten to explain what a container actually is and why this approach helps, instead of jumping straight into installation. The list of compatible apps is now presented as a set of examples (not exhaustive), each one linked.
|
||||||
|
- **Single sign-on**: several existing articles (Immich, Nextcloud, Forgejo, Pingvin, Vaultwarden) gained a new section explaining how to log in directly with Pocket ID, alongside the already-documented TinyAuth method.
|
||||||
|
- **TinyAuth protection**: added as a new section to many articles that didn't have it yet (monitoring, media, files, development), for putting these apps behind a login page.
|
||||||
|
|
||||||
|
## User experience (UX) changes
|
||||||
|
|
||||||
|
- **New look and feel**: the site was rebuilt from the ground up on a new component library, while keeping the old site's visual identity (colors, dark by default).
|
||||||
|
- **Navigation menu** repositioned to properly line up with the article content column (previously misaligned at some screen widths).
|
||||||
|
- **Collapsible sidebar**: the article tree on the left can now be collapsed, and starts collapsed by default instead of showing everything at once, easier to scan given how many sections there are.
|
||||||
|
- **Search button** added above the sidebar, visible on every page.
|
||||||
|
- **Step-by-step instructions**: nearly every install and configuration procedure is now visually numbered, instead of running paragraphs of text, much easier to follow.
|
||||||
|
- **Interactive folder trees**: folder structures shown in install guides are now real visual trees with folder/file icons, instead of ASCII-art text. Clicking a row copies that path to the clipboard.
|
||||||
|
- **Named, illustrated code blocks**: every code block now shows the real file name it belongs to, with a matching icon (for example, a YAML icon for a `compose.yaml`).
|
||||||
|
- **Callout boxes (notes, tips, warnings)**: colors matched to the old site's palette; some callouts can now be clicked entirely when they only contain a link to another article, instead of a plain inline text link.
|
||||||
|
- **Contributors shown at the bottom of every article**: lists everyone who has worked on a page (not just the last editor), linking to the full edit history.
|
||||||
|
- **"Other projects" links** (Gitea, Lumeex, Instameex) now also shown at the bottom of every article's table of contents, not just on the homepage.
|
||||||
|
- **Improved mobile layout**: homepage buttons, image, and spacing reorganized for small screens; "edit"/"report an issue" links now stack properly instead of overflowing on mobile.
|
||||||
|
|
||||||
|
## URL changes
|
||||||
|
|
||||||
|
- Every page now lives under an explicit language prefix, `/en/...` or `/fr/...`. Previously only French had a prefix (`/fr/...`), while English sat at the site root with no prefix at all.
|
||||||
|
- French URLs now use the same wording as their English counterparts (for example `/fr/general/networking/nat/` instead of the old `/fr/generalites/reseau/nat/`), so both languages share the exact same structure.
|
||||||
|
- **Every old link still works**: a bookmark, a link shared elsewhere, or a search-engine result pointing at an old address automatically redirects the visitor to its new equivalent.
|
||||||
|
|
||||||
|
## English/French parity
|
||||||
|
|
||||||
|
The French version is now a complete, faithful mirror of the English one: same articles, same organization, same formatting (same callouts, same numbered steps), with only the text translated. That wasn't the case before, where the two versions had drifted apart over time (articles present in one language but missing from the other, different organization).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) NuxtLabs
|
Copyright (c) 2026 Djeex
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
@@ -19,3 +19,9 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This project is built on the Docus theme (https://docus.dev), also MIT
|
||||||
|
licensed (Copyright (c) the Docus authors), which is compatible with and
|
||||||
|
distributed under the same terms above.
|
||||||
|
|||||||
@@ -1,53 +1,69 @@
|
|||||||
[](https://docus.dev)
|
<p align="center">
|
||||||
|
<img src="public/img/global/docudjeex-home.svg" align="center" width="700">
|
||||||
|
|
||||||
[![npm version][npm-version-src]][npm-version-href]
|
[](https://docu.djeex.fr/)
|
||||||
[![npm downloads][npm-downloads-src]][npm-downloads-href]
|
[](https://docu.djeex.fr/)
|
||||||
[![License][license-src]][license-href]
|
</p>
|
||||||
[![Nuxt][nuxt-src]][nuxt-href]
|
|
||||||
|
|
||||||
# Docus
|
# 🔧 Homelab docs & other dumb things
|
||||||
|
|
||||||
Documentation Theme and CLI to write beautiful docs with Markdown.
|
**Docu·djeex** is first and foremost a personal project aimed at self-hosting as many everyday services as possible, without relying on proprietary platforms (Google, Apple, Netflix, etc.).
|
||||||
|
This documentation site is built using [Nuxt.js](https://nuxt.com/), on the [Docus](https://docus.dev) theme (Nuxt UI + Nuxt Content).
|
||||||
|
|
||||||
Ship fast, flexible, and SEO-optimized documentation with beautiful design out of the box. Docus brings together the best of the Nuxt ecosystem:
|
This repository contains everything you need to edit pages, apply your changes, and redeploy the site. See [CUSTOMIZATIONS.md](CUSTOMIZATIONS.md) for everything added on top of the base Docus theme.
|
||||||
- [Nuxt 3](https://nuxt.com)
|
|
||||||
- [Nuxt Content](https://content.nuxt.com/)
|
|
||||||
- [Nuxt UI](https://ui.nuxt.com/)
|
|
||||||
- [Nuxt Image](https://image.nuxt.com/)
|
|
||||||
- [Nuxt LLMs](https://github.com/nuxtlabs/nuxt-llms)
|
|
||||||
- [Nuxt SEO](https://nuxtseo.com/)
|
|
||||||
- [UnJS ecosystem](https://unjs.io/)
|
|
||||||
- [Nuxt Studio](https://content.nuxt.com/studio)
|
|
||||||
|
|
||||||
## Contribution
|
## Requirements
|
||||||
|
|
||||||
<details>
|
- Node.js 20 or later
|
||||||
<summary>Local development</summary>
|
- npm
|
||||||
|
|
||||||
- Clone this repository
|
## Getting started
|
||||||
- Install the latest LTS version of [Node.js](https://nodejs.org/en/)
|
|
||||||
- Install dependencies using `pnpm install`
|
|
||||||
- Run prepare command using `pnpm run dev:prepare`
|
|
||||||
- Run dev documentation built on top of Docus using `pnpm run dev`
|
|
||||||
|
|
||||||
</details>
|
Install dependencies:
|
||||||
|
|
||||||
## License
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
Published under the [MIT](https://github.com/unjs/undocs/blob/main/LICENSE) license.
|
Start the dev server:
|
||||||
|
|
||||||
Docus v3 has been entirely rewritten from scratch and is inspired and copied from [undocs](https://github.com/unjs/undocs) made by [@pi0](https://github.com/pi0) 💚
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The site will be available at `http://localhost:3000`.
|
||||||
|
|
||||||
<!-- Badges -->
|
## Build
|
||||||
[npm-version-src]: https://img.shields.io/npm/v/docus/latest.svg?style=flat&colorA=020420&colorB=EEEEEE
|
|
||||||
[npm-version-href]: https://npmjs.com/package/docus
|
|
||||||
|
|
||||||
[npm-downloads-src]: https://img.shields.io/npm/dm/docus.svg?style=flat&colorA=020420&colorB=EEEEEE
|
```bash
|
||||||
[npm-downloads-href]: https://npm.chart.dev/docus
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
[license-src]: https://img.shields.io/npm/l/docus.svg?style=flat&colorA=020420&colorB=EEEEEE
|
This builds the production site (pointed at `https://docu.djeex.fr` via `NUXT_SITE_URL`) into `.output`. Run it with:
|
||||||
[license-href]: https://npmjs.com/package/docus
|
|
||||||
|
|
||||||
[nuxt-src]: https://img.shields.io/badge/Nuxt-020420?logo=nuxt.js
|
```bash
|
||||||
[nuxt-href]: https://nuxt.com
|
node .output/server/index.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
content/
|
||||||
|
├── en/ # English content, served at /en/...
|
||||||
|
└── fr/ # French content, served at /fr/...
|
||||||
|
|
||||||
|
app/
|
||||||
|
├── components/ # Custom components and overrides of Docus's own components
|
||||||
|
└── pages/ # The catch-all docs page
|
||||||
|
|
||||||
|
content.config.ts # Content collections and frontmatter schema
|
||||||
|
nuxt.config.ts # Nuxt/Docus/i18n configuration
|
||||||
|
app/app.config.ts # Theme, colors, branding
|
||||||
|
```
|
||||||
|
|
||||||
|
## Languages
|
||||||
|
|
||||||
|
- English (`en`) — default locale, served under `/en`
|
||||||
|
- French (`fr`) — served under `/fr`
|
||||||
|
|
||||||
|
Visiting `/` redirects to `/en` or `/fr` based on the visitor's browser language (or a previous choice, remembered via cookie).
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
export default defineAppConfig({
|
||||||
|
docus: {
|
||||||
|
locale: 'en',
|
||||||
|
colorMode: 'dark',
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
sub: 'header',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
title: 'Docudjeex',
|
||||||
|
logo: {
|
||||||
|
light: '/img/logo.svg',
|
||||||
|
dark: '/img/logo.svg',
|
||||||
|
alt: 'Docudjeex',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
socials: {
|
||||||
|
gitea: 'https://git.djeex.fr/Djeex/docudjeex',
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
url: 'https://github.com/Djeex/docudjeex',
|
||||||
|
},
|
||||||
|
toc: {
|
||||||
|
bottom: {
|
||||||
|
title: 'Other dumb things',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
icon: 'i-cib-gitea',
|
||||||
|
label: 'git.djeex.fr',
|
||||||
|
to: 'https://git.djeex.fr',
|
||||||
|
target: '_blank',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'i-brand-lumeex',
|
||||||
|
label: 'Lumeex',
|
||||||
|
to: 'https://lumeex.djeex.fr',
|
||||||
|
target: '_blank',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'i-brand-instameex',
|
||||||
|
label: 'Instameex',
|
||||||
|
to: 'https://instameex.djeex.fr',
|
||||||
|
target: '_blank',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
colors: {
|
||||||
|
primary: 'cyan',
|
||||||
|
neutral: 'zinc',
|
||||||
|
},
|
||||||
|
prose: {
|
||||||
|
card: {
|
||||||
|
slots: {
|
||||||
|
base: 'bg-[rgba(12,13,12,0.8)] border-[#121110]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Custom code-block header icons. Full labels (```text [Arborescence])
|
||||||
|
// match by exact lowercase filename; bare extensions (no filename
|
||||||
|
// match) fall back to matching any ```lang [*.ext] label.
|
||||||
|
codeIcon: {
|
||||||
|
'arborescence': 'i-lucide-folder-tree',
|
||||||
|
'directory tree': 'i-lucide-folder-tree',
|
||||||
|
'ini': 'i-lucide-settings',
|
||||||
|
'conf': 'i-lucide-settings',
|
||||||
|
'service': 'i-lucide-settings',
|
||||||
|
// Used as ::code-group tab labels when a command differs per OS.
|
||||||
|
'macos': 'i-simple-icons-apple',
|
||||||
|
'linux': 'i-simple-icons-linux',
|
||||||
|
'windows': 'i-simple-icons-windows',
|
||||||
|
},
|
||||||
|
pre: {
|
||||||
|
slots: {
|
||||||
|
base: 'bg-[#121110] border-[#201e1b] rounded-lg',
|
||||||
|
header: 'bg-[#121110] border-[#201e1b]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Exact colors measured on the old site's ::alert boxes (note=info,
|
||||||
|
// tip=success, warning=warning, caution=danger/error).
|
||||||
|
callout: {
|
||||||
|
compoundVariants: [
|
||||||
|
{
|
||||||
|
color: 'info',
|
||||||
|
class: {
|
||||||
|
base: 'border-[#002235] bg-[#00131D] text-[#64C7FF]',
|
||||||
|
icon: 'text-[#64C7FF]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'success',
|
||||||
|
class: {
|
||||||
|
base: 'border-[#002817] bg-[#00190F] text-[#3CEEA5]',
|
||||||
|
icon: 'text-[#3CEEA5]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'warning',
|
||||||
|
class: {
|
||||||
|
base: 'border-[#292100] bg-[#1B1500] text-[#FFDC4E]',
|
||||||
|
icon: 'text-[#FFDC4E]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'error',
|
||||||
|
class: {
|
||||||
|
base: 'border-[#340A01] bg-[#1C0301] text-[#FFA692]',
|
||||||
|
icon: 'text-[#FFA692]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
slots: {
|
||||||
|
root: 'bg-[rgba(12,13,12,0.8)] backdrop-blur-[20px] backdrop-saturate-200 border-b border-default h-(--ui-header-height) sticky top-0 z-50',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contentSearchButton: {
|
||||||
|
slots: {
|
||||||
|
base: 'bg-[rgba(12,13,12,0.8)] hover:bg-[rgba(18,17,16,0.9)] border border-[#121110]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contentSurround: {
|
||||||
|
slots: {
|
||||||
|
link: 'bg-[rgba(12,13,12,0.8)] border-[#121110] hover:bg-primary/10 hover:border-primary',
|
||||||
|
linkLeading: 'bg-[rgba(12,13,12,0.8)] ring-1 ring-[var(--ui-text-highlighted)]/50 group-hover:bg-primary/10 group-hover:ring-primary/50',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kbd: {
|
||||||
|
compoundVariants: [
|
||||||
|
{
|
||||||
|
color: 'neutral',
|
||||||
|
variant: 'subtle',
|
||||||
|
class: 'ring-[#121110] bg-[rgba(12,13,12,0.8)] text-default',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
/* Restore the old site's near-black dark background (#0B0A0A) instead of
|
||||||
|
Nuxt UI's default zinc-900 */
|
||||||
|
.dark {
|
||||||
|
--ui-bg: #0B0A0A;
|
||||||
|
/* Same border color used on cards, applied sitewide (header, separators,
|
||||||
|
the horizontal nav menu row, etc.) for a consistent look */
|
||||||
|
--ui-border: #121110;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Old site's container was 1280px with 24px padding each side (1232px of
|
||||||
|
actual content). Nuxt UI's container uses a bigger lg:px-8 (32px) padding,
|
||||||
|
so the max-width is bumped to 81rem (1296px) to land on the same 1232px
|
||||||
|
content width rather than reproducing the outer 1280px figure verbatim. */
|
||||||
|
:root {
|
||||||
|
--ui-container: 81rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Docus hardcodes --ui-header-height to 112px (64px header + 48px sub-nav
|
||||||
|
bar) whenever navigation.sub is 'header', regardless of what's actually
|
||||||
|
in that bar. Our horizontal menu now lives in the main header row itself
|
||||||
|
(AppHeaderCenter.vue) and the sub-nav bar (AppHeaderBottom.vue) is empty,
|
||||||
|
so the header is back to a single 64px row. */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.docus-sub-header {
|
||||||
|
--ui-header-height: 4rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A screenshot inside a list item (the step-by-step install guides) is
|
||||||
|
rendered as a bare <img> child of the <li> and gets no margin at all, so it
|
||||||
|
ends up glued to the text above and below it. The same image in a paragraph
|
||||||
|
is wrapped in a <p> that carries the prose spacing. Give it that spacing
|
||||||
|
back so illustrated steps stay readable. */
|
||||||
|
li > img {
|
||||||
|
margin-block: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
export default defineAppConfig({
|
|
||||||
ui: {
|
|
||||||
colors: {
|
|
||||||
primary: 'emerald',
|
|
||||||
neutral: 'zinc',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
uiPro: {
|
|
||||||
contentNavigation: {
|
|
||||||
slots: {
|
|
||||||
linkLeadingIcon: 'size-4 mr-1',
|
|
||||||
listWithChildren: 'border-(--ui-bg-elevated)',
|
|
||||||
linkTrailing: 'hidden',
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
active: {
|
|
||||||
false: {
|
|
||||||
link: 'text-toned hover:after:bg-accented',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: 'link',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pageLinks: {
|
|
||||||
slots: {
|
|
||||||
linkLeadingIcon: 'size-4',
|
|
||||||
linkLabelExternalIcon: 'size-2.5',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
toc: {
|
|
||||||
title: 'On this page',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const { seo } = useAppConfig()
|
|
||||||
const site = useSiteConfig()
|
|
||||||
|
|
||||||
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs'), {
|
|
||||||
transform: data => data.find(item => item.path === '/docs')?.children || data || [],
|
|
||||||
})
|
|
||||||
const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSections('docs'), {
|
|
||||||
server: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
meta: [
|
|
||||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
|
||||||
],
|
|
||||||
link: [
|
|
||||||
{ rel: 'icon', href: '/favicon.ico' },
|
|
||||||
],
|
|
||||||
htmlAttrs: {
|
|
||||||
lang: 'en',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
useSeoMeta({
|
|
||||||
titleTemplate: seo.titleTemplate,
|
|
||||||
title: seo.title,
|
|
||||||
description: seo.description,
|
|
||||||
ogSiteName: site.name,
|
|
||||||
twitterCard: 'summary_large_image',
|
|
||||||
})
|
|
||||||
|
|
||||||
provide('navigation', navigation)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UApp>
|
|
||||||
<NuxtLoadingIndicator color="var(--ui-primary)" />
|
|
||||||
|
|
||||||
<AppHeader />
|
|
||||||
|
|
||||||
<UMain>
|
|
||||||
<NuxtLayout>
|
|
||||||
<NuxtPage />
|
|
||||||
</NuxtLayout>
|
|
||||||
</UMain>
|
|
||||||
|
|
||||||
<AppFooter />
|
|
||||||
|
|
||||||
<ClientOnly>
|
|
||||||
<LazyUContentSearch
|
|
||||||
:files="files"
|
|
||||||
:navigation="navigation"
|
|
||||||
/>
|
|
||||||
</ClientOnly>
|
|
||||||
</UApp>
|
|
||||||
</template>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@import "@nuxt/ui-pro";
|
|
||||||
|
|
||||||
@source "../../../content/**/*";
|
|
||||||
@source "../../app.config.ts";
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { motion } from 'motion-v'
|
|
||||||
import type { VariantType } from 'motion-v'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
open: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const variants: { [k: string]: VariantType | ((custom: unknown) => VariantType) } = {
|
|
||||||
normal: {
|
|
||||||
rotate: 0,
|
|
||||||
y: 0,
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
close: (custom: unknown) => {
|
|
||||||
const c = custom as number
|
|
||||||
return {
|
|
||||||
rotate: c === 1 ? 45 : c === 3 ? -45 : 0,
|
|
||||||
y: c === 1 ? 6 : c === 3 ? -6 : 0,
|
|
||||||
opacity: c === 2 ? 0 : 1,
|
|
||||||
transition: {
|
|
||||||
type: 'spring',
|
|
||||||
stiffness: 260,
|
|
||||||
damping: 20,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = computed(() => props.open ? 'close' : 'normal')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UButton
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
color="neutral"
|
|
||||||
class="-me-1.5"
|
|
||||||
square
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="size-5"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<motion.line
|
|
||||||
x1="4"
|
|
||||||
y1="6"
|
|
||||||
x2="20"
|
|
||||||
y2="6"
|
|
||||||
:variants="variants"
|
|
||||||
:animate="state"
|
|
||||||
:custom="1"
|
|
||||||
class="outline-none"
|
|
||||||
/>
|
|
||||||
<motion.line
|
|
||||||
x1="4"
|
|
||||||
y1="12"
|
|
||||||
x2="20"
|
|
||||||
y2="12"
|
|
||||||
:variants="variants"
|
|
||||||
:animate="state"
|
|
||||||
:custom="2"
|
|
||||||
class="outline-none"
|
|
||||||
/>
|
|
||||||
<motion.line
|
|
||||||
x1="4"
|
|
||||||
y1="18"
|
|
||||||
x2="20"
|
|
||||||
y2="18"
|
|
||||||
:variants="variants"
|
|
||||||
:animate="state"
|
|
||||||
:custom="3"
|
|
||||||
class="outline-none"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</UButton>
|
|
||||||
</template>
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
const props = withDefaults(defineProps<{ title?: string, description?: string, headline?: string }>(), {
|
|
||||||
title: 'title',
|
|
||||||
description: 'description',
|
|
||||||
})
|
|
||||||
|
|
||||||
const title = computed(() => (props.title || '').slice(0, 60))
|
|
||||||
const description = computed(() => (props.description || '').slice(0, 200))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="w-full h-full flex flex-col justify-center bg-neutral-900">
|
|
||||||
<svg
|
|
||||||
class="absolute right-0 top-0 opacity-50"
|
|
||||||
width="629"
|
|
||||||
height="593"
|
|
||||||
viewBox="0 0 629 593"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<g filter="url(#filter0_f_199_94966)">
|
|
||||||
<path
|
|
||||||
d="M628.5 -578L639.334 -94.4223L806.598 -548.281L659.827 -87.387L965.396 -462.344L676.925 -74.0787L1087.69 -329.501L688.776 -55.9396L1160.22 -164.149L694.095 -34.9354L1175.13 15.7948L692.306 -13.3422L1130.8 190.83L683.602 6.50012L1032.04 341.989L668.927 22.4412L889.557 452.891L649.872 32.7537L718.78 511.519L628.5 36.32L538.22 511.519L607.128 32.7537L367.443 452.891L588.073 22.4412L224.955 341.989L573.398 6.50012L126.198 190.83L564.694 -13.3422L81.8734 15.7948L562.905 -34.9354L96.7839 -164.149L568.224 -55.9396L169.314 -329.501L580.075 -74.0787L291.604 -462.344L597.173 -87.387L450.402 -548.281L617.666 -94.4223L628.5 -578Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<filter
|
|
||||||
id="filter0_f_199_94966"
|
|
||||||
x="0.873535"
|
|
||||||
y="-659"
|
|
||||||
width="1255.25"
|
|
||||||
height="1251.52"
|
|
||||||
filterUnits="userSpaceOnUse"
|
|
||||||
color-interpolation-filters="sRGB"
|
|
||||||
>
|
|
||||||
<feFlood
|
|
||||||
flood-opacity="0"
|
|
||||||
result="BackgroundImageFix"
|
|
||||||
/>
|
|
||||||
<feBlend
|
|
||||||
mode="normal"
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="BackgroundImageFix"
|
|
||||||
result="shape"
|
|
||||||
/>
|
|
||||||
<feGaussianBlur
|
|
||||||
stdDeviation="40.5"
|
|
||||||
result="effect1_foregroundBlur_199_94966"
|
|
||||||
/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div class="pl-[100px]">
|
|
||||||
<p
|
|
||||||
v-if="headline"
|
|
||||||
class="uppercase text-[24px] text-emerald-500 mb-4 font-semibold"
|
|
||||||
>
|
|
||||||
{{ headline }}
|
|
||||||
</p>
|
|
||||||
<h1
|
|
||||||
v-if="title"
|
|
||||||
class="m-0 text-[75px] font-semibold mb-4 text-white flex items-center"
|
|
||||||
>
|
|
||||||
<span>{{ title }}</span>
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
v-if="description"
|
|
||||||
class="text-[32px] text-neutral-300 leading-tight w-[700px]"
|
|
||||||
>
|
|
||||||
{{ description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
const props = withDefaults(defineProps<{ title?: string, description?: string, headline?: string }>(), {
|
|
||||||
title: 'title',
|
|
||||||
description: 'description',
|
|
||||||
})
|
|
||||||
|
|
||||||
const title = computed(() => (props.title || '').slice(0, 60))
|
|
||||||
const description = computed(() => (props.description || '').slice(0, 200))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="w-full h-full flex items-center justify-center bg-neutral-900">
|
|
||||||
<svg
|
|
||||||
class="absolute right-0 top-0 opacity-50 "
|
|
||||||
width="629"
|
|
||||||
height="593"
|
|
||||||
viewBox="0 0 629 593"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<g filter="url(#filter0_f_199_94966)">
|
|
||||||
<path
|
|
||||||
d="M628.5 -578L639.334 -94.4223L806.598 -548.281L659.827 -87.387L965.396 -462.344L676.925 -74.0787L1087.69 -329.501L688.776 -55.9396L1160.22 -164.149L694.095 -34.9354L1175.13 15.7948L692.306 -13.3422L1130.8 190.83L683.602 6.50012L1032.04 341.989L668.927 22.4412L889.557 452.891L649.872 32.7537L718.78 511.519L628.5 36.32L538.22 511.519L607.128 32.7537L367.443 452.891L588.073 22.4412L224.955 341.989L573.398 6.50012L126.198 190.83L564.694 -13.3422L81.8734 15.7948L562.905 -34.9354L96.7839 -164.149L568.224 -55.9396L169.314 -329.501L580.075 -74.0787L291.604 -462.344L597.173 -87.387L450.402 -548.281L617.666 -94.4223L628.5 -578Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<filter
|
|
||||||
id="filter0_f_199_94966"
|
|
||||||
x="0.873535"
|
|
||||||
y="-659"
|
|
||||||
width="1255.25"
|
|
||||||
height="1251.52"
|
|
||||||
filterUnits="userSpaceOnUse"
|
|
||||||
color-interpolation-filters="sRGB"
|
|
||||||
>
|
|
||||||
<feFlood
|
|
||||||
flood-opacity="0"
|
|
||||||
result="BackgroundImageFix"
|
|
||||||
/>
|
|
||||||
<feBlend
|
|
||||||
mode="normal"
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="BackgroundImageFix"
|
|
||||||
result="shape"
|
|
||||||
/>
|
|
||||||
<feGaussianBlur
|
|
||||||
stdDeviation="40.5"
|
|
||||||
result="effect1_foregroundBlur_199_94966"
|
|
||||||
/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div class="flex flex-col justify-center p-8">
|
|
||||||
<div class="flex justify-center mb-8">
|
|
||||||
<AppHeaderLogo white />
|
|
||||||
</div>
|
|
||||||
<h1
|
|
||||||
v-if="title"
|
|
||||||
class="flex justify-center m-0 text-5xl font-semibold mb-4 text-white"
|
|
||||||
>
|
|
||||||
<span>{{ title }}</span>
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
v-if="description"
|
|
||||||
class="text-center text-2xl text-neutral-300 leading-tight"
|
|
||||||
>
|
|
||||||
{{ description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const appConfig = useAppConfig()
|
|
||||||
|
|
||||||
const links = computed(() => [
|
|
||||||
...Object.entries(appConfig.socials || {}).map(([key, url]) => ({
|
|
||||||
'icon': `i-simple-icons-${key}`,
|
|
||||||
'to': url,
|
|
||||||
'target': '_blank',
|
|
||||||
'aria-label': `${key} social link`,
|
|
||||||
})),
|
|
||||||
appConfig.github?.url && {
|
|
||||||
'icon': 'i-simple-icons-github',
|
|
||||||
'to': appConfig.github.url,
|
|
||||||
'target': '_blank',
|
|
||||||
'aria-label': 'GitHub repository',
|
|
||||||
},
|
|
||||||
].filter(Boolean))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UFooter>
|
|
||||||
<template #left>
|
|
||||||
<div class="text-sm text-muted">
|
|
||||||
Copyright © {{ new Date().getFullYear() }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #right>
|
|
||||||
<template v-if="links.length">
|
|
||||||
<UButton
|
|
||||||
v-for="(link, index) of links"
|
|
||||||
:key="index"
|
|
||||||
size="sm"
|
|
||||||
v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<UColorModeButton />
|
|
||||||
</template>
|
|
||||||
</UFooter>
|
|
||||||
</template>
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const appConfig = useAppConfig()
|
|
||||||
const site = useSiteConfig()
|
|
||||||
|
|
||||||
const links = computed(() => appConfig.github?.url
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
'icon': 'i-simple-icons-github',
|
|
||||||
'to': appConfig.github.url,
|
|
||||||
'target': '_blank',
|
|
||||||
'aria-label': 'GitHub',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UHeader
|
|
||||||
:ui="{ center: 'flex-1' }"
|
|
||||||
to="/"
|
|
||||||
:title="appConfig.header?.title || site.name"
|
|
||||||
>
|
|
||||||
<AppHeaderCenter />
|
|
||||||
|
|
||||||
<template #title>
|
|
||||||
<AppHeaderLogo class="h-6 w-auto shrink-0" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #right>
|
|
||||||
<AppHeaderCTA />
|
|
||||||
|
|
||||||
<UContentSearchButton class="lg:hidden" />
|
|
||||||
|
|
||||||
<UColorModeButton />
|
|
||||||
|
|
||||||
<template v-if="links?.length">
|
|
||||||
<UButton
|
|
||||||
v-for="(link, index) of links"
|
|
||||||
:key="index"
|
|
||||||
v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #toggle="{ open, toggle }">
|
|
||||||
<IconMenuToggle
|
|
||||||
:open="open"
|
|
||||||
class="lg:hidden"
|
|
||||||
@click="toggle"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #body>
|
|
||||||
<AppHeaderBody />
|
|
||||||
</template>
|
|
||||||
</UHeader>
|
|
||||||
</template>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ContentNavigationItem } from '@nuxt/content'
|
|
||||||
|
|
||||||
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UContentNavigation
|
|
||||||
highlight
|
|
||||||
variant="link"
|
|
||||||
:navigation="navigation"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<template>
|
|
||||||
<UContentSearchButton
|
|
||||||
:collapsed="false"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const appConfig = useAppConfig()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UColorModeImage
|
|
||||||
v-if="appConfig.header?.logo?.dark || appConfig.header?.logo?.light"
|
|
||||||
:light="appConfig.header?.logo?.light || appConfig.header?.logo?.dark"
|
|
||||||
:dark="appConfig.header?.logo?.dark || appConfig.header?.logo?.light"
|
|
||||||
:alt="appConfig.header?.logo?.alt || appConfig.header?.title"
|
|
||||||
class="h-6 w-auto shrink-0"
|
|
||||||
/>
|
|
||||||
<span v-else>
|
|
||||||
{{ appConfig.header?.title || '{appConfig.header.title}' }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div />
|
|
||||||
</template>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const appConfig = useAppConfig()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
v-if="appConfig.toc?.bottom?.links?.length"
|
|
||||||
class="hidden lg:block space-y-6"
|
|
||||||
>
|
|
||||||
<USeparator type="dashed" />
|
|
||||||
|
|
||||||
<UPageLinks
|
|
||||||
:title="appConfig.toc?.bottom?.title || 'Links'"
|
|
||||||
:links="appConfig.toc?.bottom?.links"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useClipboard } from '@vueuse/core'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const toast = useToast()
|
|
||||||
const { copy, copied } = useClipboard()
|
|
||||||
|
|
||||||
const markdownLink = computed(() => `${window?.location?.origin}/raw${route.path}.md`)
|
|
||||||
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
label: 'Copy Markdown link',
|
|
||||||
icon: 'i-lucide-link',
|
|
||||||
onSelect() {
|
|
||||||
copy(markdownLink.value)
|
|
||||||
|
|
||||||
toast.add({
|
|
||||||
title: 'Markdown link copied to clipboard',
|
|
||||||
icon: 'i-lucide-check-circle',
|
|
||||||
color: 'success',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'View as Markdown',
|
|
||||||
icon: 'i-simple-icons:markdown',
|
|
||||||
target: '_blank',
|
|
||||||
to: markdownLink.value,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Open in ChatGPT',
|
|
||||||
icon: 'i-simple-icons:openai',
|
|
||||||
target: '_blank',
|
|
||||||
to: `https://chatgpt.com/?hints=search&q=${encodeURIComponent(`Read ${markdownLink.value} so I can ask questions about it.`)}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Open in Claude',
|
|
||||||
icon: 'i-simple-icons:anthropic',
|
|
||||||
target: '_blank',
|
|
||||||
to: `https://claude.ai/new?q=${encodeURIComponent(`Read ${markdownLink.value} so I can ask questions about it.`)}`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UButtonGroup size="sm">
|
|
||||||
<UButton
|
|
||||||
label="Copy page"
|
|
||||||
:icon="copied ? 'i-lucide-copy-check' : 'i-lucide-copy'"
|
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
:ui="{
|
|
||||||
leadingIcon: [copied ? 'text-primary' : 'text-neutral', 'size-3.5'],
|
|
||||||
}"
|
|
||||||
@click="copy(markdownLink)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UDropdownMenu
|
|
||||||
size="sm"
|
|
||||||
:items="items"
|
|
||||||
:content="{
|
|
||||||
align: 'end',
|
|
||||||
side: 'bottom',
|
|
||||||
sideOffset: 8,
|
|
||||||
}"
|
|
||||||
:ui="{
|
|
||||||
content: 'w-48',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<UButton
|
|
||||||
icon="i-lucide-chevron-down"
|
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
/>
|
|
||||||
</UDropdownMenu>
|
|
||||||
</UButtonGroup>
|
|
||||||
</template>
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { NuxtError } from '#app'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
error: NuxtError
|
|
||||||
}>()
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
htmlAttrs: {
|
|
||||||
lang: 'en',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
useSeoMeta({
|
|
||||||
title: 'Page not found',
|
|
||||||
description: 'We are sorry but this page could not be found.',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs'))
|
|
||||||
const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSections('docs'), {
|
|
||||||
server: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
provide('navigation', navigation)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UApp>
|
|
||||||
<AppHeader />
|
|
||||||
|
|
||||||
<UError :error="error" />
|
|
||||||
|
|
||||||
<AppFooter />
|
|
||||||
|
|
||||||
<ClientOnly>
|
|
||||||
<LazyUContentSearch
|
|
||||||
:files="files"
|
|
||||||
:navigation="navigation"
|
|
||||||
/>
|
|
||||||
</ClientOnly>
|
|
||||||
</UApp>
|
|
||||||
</template>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ContentNavigationItem } from '@nuxt/content'
|
|
||||||
|
|
||||||
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UContainer>
|
|
||||||
<UPage>
|
|
||||||
<template #left>
|
|
||||||
<UPageAside>
|
|
||||||
<DocsAsideLeftTop />
|
|
||||||
|
|
||||||
<UContentNavigation
|
|
||||||
highlight
|
|
||||||
:navigation="navigation"
|
|
||||||
/>
|
|
||||||
</UPageAside>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<slot />
|
|
||||||
</UPage>
|
|
||||||
</UContainer>
|
|
||||||
</template>
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { kebabCase } from 'scule'
|
|
||||||
import type { ContentNavigationItem } from '@nuxt/content'
|
|
||||||
import { findPageHeadline } from '@nuxt/content/utils'
|
|
||||||
import { addPrerenderPath } from '../utils/prerender'
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
layout: 'docs',
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const appConfig = useAppConfig()
|
|
||||||
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
|
|
||||||
|
|
||||||
const [{ data: page }, { data: surround }] = await Promise.all([
|
|
||||||
useAsyncData(kebabCase(route.path), () => queryCollection('docs').path(route.path).first()),
|
|
||||||
useAsyncData(`${kebabCase(route.path)}-surround`, () => {
|
|
||||||
return queryCollectionItemSurroundings('docs', route.path, {
|
|
||||||
fields: ['description'],
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!page.value) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the page path to the prerender list
|
|
||||||
addPrerenderPath(`/raw${route.path}.md`)
|
|
||||||
|
|
||||||
const title = page.value.seo?.title || page.value.title
|
|
||||||
const description = page.value.seo?.description || page.value.description
|
|
||||||
|
|
||||||
useSeoMeta({
|
|
||||||
title,
|
|
||||||
ogTitle: title,
|
|
||||||
description,
|
|
||||||
ogDescription: description,
|
|
||||||
})
|
|
||||||
|
|
||||||
const headline = computed(() => findPageHeadline(navigation?.value, page.value?.path))
|
|
||||||
defineOgImageComponent('Docs', {
|
|
||||||
headline: headline.value,
|
|
||||||
})
|
|
||||||
|
|
||||||
const editLink = computed(() => {
|
|
||||||
if (!appConfig.github) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
appConfig.github.url,
|
|
||||||
'edit',
|
|
||||||
appConfig.github.branch,
|
|
||||||
appConfig.github.rootDir,
|
|
||||||
'content',
|
|
||||||
`${page.value?.stem}.${page.value?.extension}`,
|
|
||||||
].filter(Boolean).join('/')
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UPage v-if="page">
|
|
||||||
<UPageHeader
|
|
||||||
:title="page.title"
|
|
||||||
:description="page.description"
|
|
||||||
:headline="headline"
|
|
||||||
:ui="{
|
|
||||||
wrapper: 'flex-row items-center flex-wrap justify-between',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #links>
|
|
||||||
<UButton
|
|
||||||
v-for="(link, index) in page.links"
|
|
||||||
:key="index"
|
|
||||||
size="sm"
|
|
||||||
v-bind="link"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DocsPageHeaderLinks />
|
|
||||||
</template>
|
|
||||||
</UPageHeader>
|
|
||||||
|
|
||||||
<UPageBody>
|
|
||||||
<ContentRenderer
|
|
||||||
v-if="page"
|
|
||||||
:value="page"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<USeparator>
|
|
||||||
<div
|
|
||||||
v-if="editLink"
|
|
||||||
class="flex items-center gap-2 text-sm text-muted"
|
|
||||||
>
|
|
||||||
<UButton
|
|
||||||
variant="link"
|
|
||||||
color="neutral"
|
|
||||||
:to="editLink"
|
|
||||||
target="_blank"
|
|
||||||
icon="i-lucide-pen"
|
|
||||||
:ui="{ leadingIcon: 'size-4' }"
|
|
||||||
>
|
|
||||||
Edit this page
|
|
||||||
</UButton>
|
|
||||||
or
|
|
||||||
<UButton
|
|
||||||
variant="link"
|
|
||||||
color="neutral"
|
|
||||||
:to="`${appConfig.github.url}/issues/new/choose`"
|
|
||||||
target="_blank"
|
|
||||||
icon="i-lucide-alert-circle"
|
|
||||||
:ui="{ leadingIcon: 'size-4' }"
|
|
||||||
>
|
|
||||||
Report an issue
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</USeparator>
|
|
||||||
<UContentSurround :surround="surround" />
|
|
||||||
</UPageBody>
|
|
||||||
|
|
||||||
<template
|
|
||||||
v-if="page?.body?.toc?.links?.length"
|
|
||||||
#right
|
|
||||||
>
|
|
||||||
<UContentToc
|
|
||||||
highlight
|
|
||||||
:title="appConfig.toc?.title || 'Table of Contents'"
|
|
||||||
:links="page.body?.toc?.links"
|
|
||||||
>
|
|
||||||
<template #bottom>
|
|
||||||
<DocsAsideRightBottom />
|
|
||||||
</template>
|
|
||||||
</UContentToc>
|
|
||||||
</template>
|
|
||||||
</UPage>
|
|
||||||
</template>
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const { data: page } = await useAsyncData('index', () => queryCollection('landing').path('/').first())
|
|
||||||
if (!page.value) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reconsider it once this is implemented: https://github.com/nuxt/content/issues/3419
|
|
||||||
const prose = page.value.meta.prose as boolean
|
|
||||||
const title = page.value.seo?.title || page.value.title
|
|
||||||
const description = page.value.seo?.description || page.value.description
|
|
||||||
|
|
||||||
useSeoMeta({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
ogTitle: title,
|
|
||||||
ogDescription: description,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (page.value?.seo?.ogImage) {
|
|
||||||
useSeoMeta({
|
|
||||||
ogImage: page.value.seo.ogImage,
|
|
||||||
twitterImage: page.value.seo.ogImage,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
defineOgImageComponent('Landing', {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ContentRenderer
|
|
||||||
v-if="page"
|
|
||||||
:value="page"
|
|
||||||
:prose="prose || false"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
Vendored
-39
@@ -1,39 +0,0 @@
|
|||||||
declare module 'nuxt/schema' {
|
|
||||||
interface AppConfig {
|
|
||||||
seo: {
|
|
||||||
titleTemplate: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
header: {
|
|
||||||
title: string
|
|
||||||
logo: {
|
|
||||||
light: string
|
|
||||||
dark: string
|
|
||||||
alt: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
socials: Record<string, string>
|
|
||||||
toc: {
|
|
||||||
title: string
|
|
||||||
bottom: {
|
|
||||||
title: string
|
|
||||||
links: {
|
|
||||||
icon: string
|
|
||||||
label: string
|
|
||||||
to: string
|
|
||||||
target: string
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
github: {
|
|
||||||
owner: string
|
|
||||||
name: string
|
|
||||||
url: string
|
|
||||||
branch: string
|
|
||||||
rootDir?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { execSync } from 'node:child_process'
|
|
||||||
import { readGitConfig } from 'pkg-types'
|
|
||||||
import gitUrlParse from 'git-url-parse'
|
|
||||||
|
|
||||||
export interface GitInfo {
|
|
||||||
// Repository name
|
|
||||||
name: string
|
|
||||||
// Repository owner/organization
|
|
||||||
owner: string
|
|
||||||
// Repository URL
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getGitBranch() {
|
|
||||||
const envName
|
|
||||||
= process.env.CF_PAGES_BRANCH
|
|
||||||
|| process.env.CI_COMMIT_BRANCH
|
|
||||||
|| process.env.VERCEL_GIT_COMMIT_REF
|
|
||||||
|| process.env.BRANCH
|
|
||||||
|| process.env.GITHUB_REF_NAME
|
|
||||||
|
|
||||||
if (envName && envName !== 'HEAD') {
|
|
||||||
return envName
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const branch = execSync('git rev-parse --abbrev-ref HEAD').toString().trim()
|
|
||||||
if (branch && branch !== 'HEAD') {
|
|
||||||
return branch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
// Ignore error
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'main'
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getLocalGitInfo(rootDir: string): Promise<GitInfo | undefined> {
|
|
||||||
const remote = await getLocalGitRemote(rootDir)
|
|
||||||
if (!remote) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://www.npmjs.com/package/git-url-parse#clipboard-example
|
|
||||||
const { name, owner, source } = gitUrlParse(remote)
|
|
||||||
const url = `https://${source}/${owner}/${name}`
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
owner,
|
|
||||||
url,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getLocalGitRemote(dir: string): Promise<string | undefined> {
|
|
||||||
try {
|
|
||||||
const parsed = await readGitConfig(dir)
|
|
||||||
if (!parsed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return parsed.remote?.['origin']?.url
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
// Ignore error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getGitEnv(): GitInfo {
|
|
||||||
// https://github.com/unjs/std-env/issues/59
|
|
||||||
const envInfo = {
|
|
||||||
// Provider
|
|
||||||
provider: process.env.VERCEL_GIT_PROVIDER // vercel
|
|
||||||
|| (process.env.GITHUB_SERVER_URL ? 'github' : undefined) // github
|
|
||||||
|| '',
|
|
||||||
// Owner
|
|
||||||
owner: process.env.VERCEL_GIT_REPO_OWNER // vercel
|
|
||||||
|| process.env.GITHUB_REPOSITORY_OWNER // github
|
|
||||||
|| process.env.CI_PROJECT_PATH?.split('/').shift() // gitlab
|
|
||||||
|| '',
|
|
||||||
// Name
|
|
||||||
name: process.env.VERCEL_GIT_REPO_SLUG
|
|
||||||
|| process.env.GITHUB_REPOSITORY?.split('/').pop() // github
|
|
||||||
|| process.env.CI_PROJECT_PATH?.split('/').splice(1).join('/') // gitlab
|
|
||||||
|| '',
|
|
||||||
// Url
|
|
||||||
url: process.env.REPOSITORY_URL || '', // netlify
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!envInfo.url && envInfo.provider && envInfo.owner && envInfo.name) {
|
|
||||||
envInfo.url = `https://${envInfo.provider}.com/${envInfo.owner}/${envInfo.name}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// If only url available (ex: Netlify)
|
|
||||||
if (!envInfo.name && !envInfo.owner && envInfo.url) {
|
|
||||||
try {
|
|
||||||
const { name, owner } = gitUrlParse(envInfo.url)
|
|
||||||
envInfo.name = name
|
|
||||||
envInfo.owner = owner
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
// Ignore error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: envInfo.name,
|
|
||||||
owner: envInfo.owner,
|
|
||||||
url: envInfo.url,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { readFile } from 'node:fs/promises'
|
|
||||||
import { resolve } from 'node:path'
|
|
||||||
|
|
||||||
export function inferSiteURL() {
|
|
||||||
// https://github.com/unjs/std-env/issues/59
|
|
||||||
return (
|
|
||||||
process.env.NUXT_SITE_URL
|
|
||||||
|| (process.env.NEXT_PUBLIC_VERCEL_URL && `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`) // Vercel
|
|
||||||
|| process.env.URL // Netlify
|
|
||||||
|| process.env.CI_PAGES_URL // Gitlab Pages
|
|
||||||
|| process.env.CF_PAGES_URL // Cloudflare Pages
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPackageJsonMetadata(dir: string) {
|
|
||||||
try {
|
|
||||||
const packageJson = await readFile(resolve(dir, 'package.json'), 'utf-8')
|
|
||||||
const parsed = JSON.parse(packageJson)
|
|
||||||
return {
|
|
||||||
name: parsed.name,
|
|
||||||
description: parsed.description,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return {
|
|
||||||
name: 'docs',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
export const addPrerenderPath = (path: string) => {
|
|
||||||
const event = useRequestEvent()
|
|
||||||
if (event) {
|
|
||||||
event.node.res.setHeader(
|
|
||||||
'x-nitro-prerender',
|
|
||||||
[
|
|
||||||
event.node.res.getHeader('x-nitro-prerender'),
|
|
||||||
path,
|
|
||||||
].filter(Boolean).join(','),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 1000 1000">
|
||||||
|
<!-- Generator: Adobe Illustrator 30.7.0, SVG Export Plug-In . SVG Version: 2.1.4 Build 114) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: url(#Dégradé_sans_nom_5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st1 {
|
||||||
|
fill: url(#Dégradé_sans_nom_264);
|
||||||
|
stroke: url(#Dégradé_sans_nom_334);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st1, .st2, .st3, .st4, .st5, .st6, .st7, .st8, .st9, .st10, .st11, .st12 {
|
||||||
|
stroke-miterlimit: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st2 {
|
||||||
|
fill: url(#Dégradé_sans_nom_262);
|
||||||
|
stroke: url(#Dégradé_sans_nom_332);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st3 {
|
||||||
|
fill: url(#Dégradé_sans_nom_269);
|
||||||
|
stroke: url(#Dégradé_sans_nom_339);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st4 {
|
||||||
|
fill: url(#Dégradé_sans_nom_263);
|
||||||
|
stroke: url(#Dégradé_sans_nom_333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st5 {
|
||||||
|
fill: url(#Dégradé_sans_nom_265);
|
||||||
|
stroke: url(#Dégradé_sans_nom_335);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st6 {
|
||||||
|
fill: url(#Dégradé_sans_nom_268);
|
||||||
|
stroke: url(#Dégradé_sans_nom_338);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st7 {
|
||||||
|
fill: url(#Dégradé_sans_nom_261);
|
||||||
|
stroke: url(#Dégradé_sans_nom_331);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st8 {
|
||||||
|
fill: url(#Dégradé_sans_nom_266);
|
||||||
|
stroke: url(#Dégradé_sans_nom_336);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st9 {
|
||||||
|
fill: url(#Dégradé_sans_nom_267);
|
||||||
|
stroke: url(#Dégradé_sans_nom_337);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st10 {
|
||||||
|
fill: url(#Dégradé_sans_nom_26);
|
||||||
|
stroke: url(#Dégradé_sans_nom_33);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st11 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2610);
|
||||||
|
stroke: url(#Dégradé_sans_nom_3310);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st12 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2611);
|
||||||
|
stroke: url(#Dégradé_sans_nom_3311);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st13 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st14 {
|
||||||
|
fill: url(#Dégradé_sans_nom_51);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_5" data-name="Dégradé sans nom 5" x1="245" y1="503" x2="747" y2="503" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#55c3ec"/>
|
||||||
|
<stop offset="1" stop-color="#1d71b8" stop-opacity=".8"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_51" data-name="Dégradé sans nom 5" x1="399.4" y1="504.3" x2="595.1" y2="504.3" xlink:href="#Dégradé_sans_nom_5"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_26" data-name="Dégradé sans nom 26" x1="74.5" y1="560.2" x2="106.2" y2="591.8" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#55c3ec"/>
|
||||||
|
<stop offset="1" stop-color="#1d71b8" stop-opacity=".8"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_33" data-name="Dégradé sans nom 33" x1="74.2" y1="559.9" x2="106.5" y2="592.2" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#55c3ec"/>
|
||||||
|
<stop offset="1" stop-color="#1d71b8" stop-opacity=".5"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_261" data-name="Dégradé sans nom 26" x1="158.2" y1="648.8" x2="176.7" y2="667.3" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_331" data-name="Dégradé sans nom 33" x1="157.8" y1="648.4" x2="177.1" y2="667.7" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_262" data-name="Dégradé sans nom 26" x1="210.2" y1="714.7" x2="249.8" y2="754.3" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_332" data-name="Dégradé sans nom 33" x1="209.9" y1="714.3" x2="250.2" y2="754.6" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_263" data-name="Dégradé sans nom 26" x1="-54" y1="72.2" x2="-14.4" y2="111.8" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_333" data-name="Dégradé sans nom 33" x1="-54.4" y1="71.9" x2="-14.1" y2="112.2" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_264" data-name="Dégradé sans nom 26" x1="485.1" y1="-9.2" x2="503.7" y2="9.4" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_334" data-name="Dégradé sans nom 33" x1="484.8" y1="-9.6" x2="504" y2="9.7" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_265" data-name="Dégradé sans nom 26" x1="825.1" y1="682.3" x2="856.8" y2="713.9" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_335" data-name="Dégradé sans nom 33" x1="824.8" y1="681.9" x2="857.1" y2="714.3" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_266" data-name="Dégradé sans nom 26" x1="308.5" y1="356.5" x2="340.3" y2="388.3" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_336" data-name="Dégradé sans nom 33" x1="308.1" y1="356.2" x2="340.7" y2="388.7" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_267" data-name="Dégradé sans nom 26" x1="540.4" y1="450.4" x2="559" y2="469" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_337" data-name="Dégradé sans nom 33" x1="540.1" y1="450" x2="559.4" y2="469.3" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_268" data-name="Dégradé sans nom 26" x1="-336.4" y1="326.9" x2="-296.8" y2="366.5" gradientTransform="translate(342.9 490.5) rotate(-175.4)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_338" data-name="Dégradé sans nom 33" x1="-336.7" y1="326.5" x2="-296.4" y2="366.8" gradientTransform="translate(342.9 490.5) rotate(-175.4)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_269" data-name="Dégradé sans nom 26" x1="115" y1="-29.9" x2="133.6" y2="-11.3" gradientTransform="translate(814.9 151.4) rotate(139.6)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_339" data-name="Dégradé sans nom 33" x1="114.7" y1="-30.2" x2="134" y2="-11" gradientTransform="translate(814.9 151.4) rotate(139.6)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2610" data-name="Dégradé sans nom 26" x1="94.5" y1="304.2" x2="124.4" y2="334" gradientTransform="translate(568 142) rotate(97.9)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_3310" data-name="Dégradé sans nom 33" x1="94.2" y1="303.8" x2="124.7" y2="334.4" gradientTransform="translate(568 142) rotate(97.9)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2611" data-name="Dégradé sans nom 26" x1="435.8" y1="254.1" x2="454.4" y2="272.7" gradientTransform="translate(257.1 -349) rotate(52.9)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_3311" data-name="Dégradé sans nom 33" x1="435.5" y1="253.8" x2="454.8" y2="273.1" gradientTransform="translate(257.1 -349) rotate(52.9)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
</defs>
|
||||||
|
<g id="Calque_1">
|
||||||
|
<circle class="st13" cx="499.5" cy="499.5" r="499.5"/>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M618.3,252h-244.6c-72.1,0-128.7,59.2-128.7,128.7v244.6c0,72.1,59.2,128.7,128.7,128.7h244.6c72.1,0,128.7-59.2,128.7-128.7v-244.6c0-72.1-59.2-128.7-128.7-128.7ZM497.3,656.2c-82.4,0-151.9-69.5-151.9-151.9s66.9-151.9,151.9-151.9,151.9,69.5,151.9,151.9-69.5,151.9-151.9,151.9ZM659.5,378.2c-20.6,0-36-15.4-36-36s15.4-36,36-36,36,15.4,36,36c0,20.6-15.4,36-36,36Z"/>
|
||||||
|
<path class="st14" d="M497.3,406.5c-54.1,0-97.8,43.8-97.8,97.8s43.8,97.8,97.8,97.8,97.8-43.8,97.8-97.8-43.8-97.8-97.8-97.8Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="Calque_2">
|
||||||
|
<g id="Calque_3">
|
||||||
|
<circle class="st10" cx="90.4" cy="576" r="22.4"/>
|
||||||
|
<circle class="st7" cx="175.6" cy="607.9" r="13.1"/>
|
||||||
|
<circle class="st2" cx="140.8" cy="691.6" r="28"/>
|
||||||
|
<circle class="st4" cx="829.7" cy="602.6" r="28"/>
|
||||||
|
<circle class="st1" cx="908.9" cy="562.1" r="13.1"/>
|
||||||
|
<circle class="st5" cx="840.9" cy="698.1" r="22.4"/>
|
||||||
|
<circle class="st8" cx="466.1" cy="876.5" r="22.5"/>
|
||||||
|
<circle class="st9" cx="538.6" cy="839.8" r="13.1"/>
|
||||||
|
<circle class="st6" cx="686.1" cy="170.1" r="28"/>
|
||||||
|
<circle class="st3" cx="733.7" cy="247.7" r="13.1"/>
|
||||||
|
<circle class="st11" cx="236.9" cy="206.5" r="21.1"/>
|
||||||
|
<circle class="st12" cx="315.4" cy="164.9" r="13.1"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.8 KiB |
@@ -0,0 +1,154 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 1000 1000">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: url(#Dégradé_sans_nom_265);
|
||||||
|
stroke: url(#Dégradé_sans_nom_33);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st0, .st1, .st2, .st3, .st4, .st5, .st6, .st7, .st8, .st9, .st10, .st11 {
|
||||||
|
stroke-miterlimit: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st1 {
|
||||||
|
fill: url(#Dégradé_sans_nom_269);
|
||||||
|
stroke: url(#Dégradé_sans_nom_334);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st2 {
|
||||||
|
fill: url(#Dégradé_sans_nom_268);
|
||||||
|
stroke: url(#Dégradé_sans_nom_333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st3 {
|
||||||
|
fill: url(#Dégradé_sans_nom_266);
|
||||||
|
stroke: url(#Dégradé_sans_nom_331);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st4 {
|
||||||
|
fill: url(#Dégradé_sans_nom_267);
|
||||||
|
stroke: url(#Dégradé_sans_nom_332);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st12 {
|
||||||
|
fill: url(#Dégradé_sans_nom_261);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st13 {
|
||||||
|
fill: url(#Dégradé_sans_nom_262);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st14 {
|
||||||
|
fill: url(#Dégradé_sans_nom_264);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st15 {
|
||||||
|
fill: url(#Dégradé_sans_nom_263);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st5 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2616);
|
||||||
|
stroke: url(#Dégradé_sans_nom_3311);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st6 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2615);
|
||||||
|
stroke: url(#Dégradé_sans_nom_3310);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st16 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st17 {
|
||||||
|
fill: url(#Dégradé_sans_nom_26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st7 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2610);
|
||||||
|
stroke: url(#Dégradé_sans_nom_335);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st8 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2613);
|
||||||
|
stroke: url(#Dégradé_sans_nom_338);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st9 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2614);
|
||||||
|
stroke: url(#Dégradé_sans_nom_339);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st10 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2611);
|
||||||
|
stroke: url(#Dégradé_sans_nom_336);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st11 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2612);
|
||||||
|
stroke: url(#Dégradé_sans_nom_337);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_26" data-name="Dégradé sans nom 26" x1="373.2" y1="159.5" x2="625.1" y2="411.5" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#55c3ec"/>
|
||||||
|
<stop offset="1" stop-color="#1d71b8" stop-opacity=".8"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_261" data-name="Dégradé sans nom 26" x1="143.1" y1="200.5" x2="395" y2="452.4" gradientTransform="translate(30.8 109.3)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_262" data-name="Dégradé sans nom 26" x1="81.3" y1="60.6" x2="333.2" y2="312.5" gradientTransform="translate(187.1 873.6) rotate(-90)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_263" data-name="Dégradé sans nom 26" x1="-44.4" y1="16.5" x2="207.5" y2="268.4" gradientTransform="translate(705.4 808.2) rotate(-180)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_264" data-name="Dégradé sans nom 26" x1="-67.9" y1="-58.9" x2="184" y2="193" gradientTransform="translate(770.9 385.1) rotate(90)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_265" data-name="Dégradé sans nom 26" x1="74.5" y1="560.2" x2="106.2" y2="591.8" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_33" data-name="Dégradé sans nom 33" x1="74.2" y1="559.9" x2="106.5" y2="592.2" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#55c3ec"/>
|
||||||
|
<stop offset="1" stop-color="#1d71b8" stop-opacity=".5"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_266" data-name="Dégradé sans nom 26" x1="158.2" y1="648.8" x2="176.7" y2="667.3" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_331" data-name="Dégradé sans nom 33" x1="157.8" y1="648.4" x2="177.1" y2="667.7" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_267" data-name="Dégradé sans nom 26" x1="210.2" y1="714.7" x2="249.8" y2="754.3" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_332" data-name="Dégradé sans nom 33" x1="209.9" y1="714.3" x2="250.2" y2="754.6" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_268" data-name="Dégradé sans nom 26" x1="-54" y1="72.2" x2="-14.4" y2="111.8" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_333" data-name="Dégradé sans nom 33" x1="-54.4" y1="71.9" x2="-14.1" y2="112.2" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_269" data-name="Dégradé sans nom 26" x1="485.1" y1="-9.2" x2="503.7" y2="9.4" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_334" data-name="Dégradé sans nom 33" x1="484.8" y1="-9.6" x2="504" y2="9.7" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2610" data-name="Dégradé sans nom 26" x1="825.1" y1="682.3" x2="856.8" y2="713.9" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_335" data-name="Dégradé sans nom 33" x1="824.8" y1="681.9" x2="857.1" y2="714.3" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2611" data-name="Dégradé sans nom 26" x1="308.5" y1="356.5" x2="340.3" y2="388.3" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_336" data-name="Dégradé sans nom 33" x1="308.1" y1="356.2" x2="340.7" y2="388.7" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2612" data-name="Dégradé sans nom 26" x1="540.4" y1="450.4" x2="559" y2="469" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_337" data-name="Dégradé sans nom 33" x1="540.1" y1="450" x2="559.4" y2="469.3" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2613" data-name="Dégradé sans nom 26" x1="-336.4" y1="326.9" x2="-296.8" y2="366.5" gradientTransform="translate(342.9 490.5) rotate(-175.4)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_338" data-name="Dégradé sans nom 33" x1="-336.7" y1="326.5" x2="-296.4" y2="366.8" gradientTransform="translate(342.9 490.5) rotate(-175.4)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2614" data-name="Dégradé sans nom 26" x1="115" y1="-29.9" x2="133.6" y2="-11.3" gradientTransform="translate(814.9 151.4) rotate(139.6)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_339" data-name="Dégradé sans nom 33" x1="114.7" y1="-30.2" x2="134" y2="-11" gradientTransform="translate(814.9 151.4) rotate(139.6)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2615" data-name="Dégradé sans nom 26" x1="94.5" y1="304.2" x2="124.4" y2="334" gradientTransform="translate(568 142) rotate(97.9)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_3310" data-name="Dégradé sans nom 33" x1="94.2" y1="303.8" x2="124.7" y2="334.4" gradientTransform="translate(568 142) rotate(97.9)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2616" data-name="Dégradé sans nom 26" x1="435.8" y1="254.1" x2="454.4" y2="272.7" gradientTransform="translate(257.1 -349) rotate(52.9)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_3311" data-name="Dégradé sans nom 33" x1="435.5" y1="253.8" x2="454.8" y2="273.1" gradientTransform="translate(257.1 -349) rotate(52.9)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
</defs>
|
||||||
|
<g id="Calque_1">
|
||||||
|
<circle class="st16" cx="499.5" cy="499.5" r="499.5"/>
|
||||||
|
</g>
|
||||||
|
<g id="Calque_2">
|
||||||
|
<g id="Calque_3">
|
||||||
|
<ellipse class="st17" cx="499.2" cy="285.5" rx="139.8" ry="209.5"/>
|
||||||
|
<ellipse class="st12" cx="299.9" cy="435.8" rx="139.8" ry="209.5" transform="translate(-207.3 586.3) rotate(-72)"/>
|
||||||
|
<ellipse class="st13" cx="373.6" cy="666.3" rx="209.5" ry="139.8" transform="translate(-385.1 576.9) rotate(-54)"/>
|
||||||
|
<ellipse class="st15" cx="623.9" cy="665.8" rx="139.8" ry="209.5" transform="translate(-272.2 493.9) rotate(-36)"/>
|
||||||
|
<ellipse class="st14" cx="703.9" cy="443.1" rx="209.5" ry="139.8" transform="translate(-94.9 211.2) rotate(-16)"/>
|
||||||
|
<circle class="st0" cx="90.4" cy="576" r="22.4"/>
|
||||||
|
<circle class="st3" cx="175.6" cy="607.9" r="13.1"/>
|
||||||
|
<circle class="st4" cx="140.8" cy="691.6" r="28"/>
|
||||||
|
<circle class="st2" cx="829.7" cy="602.6" r="28"/>
|
||||||
|
<circle class="st1" cx="908.9" cy="562.1" r="13.1"/>
|
||||||
|
<circle class="st7" cx="840.9" cy="698.1" r="22.4"/>
|
||||||
|
<circle class="st10" cx="466.1" cy="876.5" r="22.5"/>
|
||||||
|
<circle class="st11" cx="538.6" cy="839.8" r="13.1"/>
|
||||||
|
<circle class="st8" cx="686.1" cy="170.1" r="28"/>
|
||||||
|
<circle class="st9" cx="733.7" cy="247.7" r="13.1"/>
|
||||||
|
<circle class="st6" cx="236.9" cy="206.5" r="21.1"/>
|
||||||
|
<circle class="st5" cx="315.4" cy="164.9" r="13.1"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.6 KiB |
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
const { title, description, headline } = defineProps<{ title?: string, description?: string, headline?: string }>()
|
||||||
|
|
||||||
|
const appConfig = useAppConfig()
|
||||||
|
const { name: siteName } = useSiteConfig()
|
||||||
|
const primaryColor = appConfig.ui?.colors?.primary ?? 'emerald'
|
||||||
|
const logoPath = appConfig.header?.logo?.dark || appConfig.header?.logo?.light
|
||||||
|
const logoHeight = 40
|
||||||
|
|
||||||
|
const logoSvg = await fetchLogoSvg(logoPath)
|
||||||
|
|
||||||
|
async function fetchLogoSvg(path?: string): Promise<string> {
|
||||||
|
if (!path) return ''
|
||||||
|
try {
|
||||||
|
const { url: siteUrl } = useSiteConfig()
|
||||||
|
const url = path.startsWith('http') ? path : `${siteUrl}${path}`
|
||||||
|
let svg = await $fetch<string>(url, { responseType: 'text' })
|
||||||
|
|
||||||
|
// Strip the XML prolog and comments: takumi renders them as literal text
|
||||||
|
// instead of ignoring them like a browser's innerHTML would.
|
||||||
|
svg = svg.replace(/<\?xml[^>]*\?>/, '').replace(/<!--[\s\S]*?-->/g, '').trim()
|
||||||
|
|
||||||
|
// takumi doesn't resolve the SVG's own <style> class rules either (paths
|
||||||
|
// rendered black), so inline each class's fill directly, then drop <defs>.
|
||||||
|
const classFills = new Map(
|
||||||
|
[...svg.matchAll(/\.(\w+)\s*\{\s*fill:\s*([^;}\s]+)/g)].map(([, className, fill]) => [className, fill]),
|
||||||
|
)
|
||||||
|
for (const [className, fill] of classFills) {
|
||||||
|
svg = svg.replaceAll(`class="${className}"`, `fill="${fill}"`)
|
||||||
|
}
|
||||||
|
svg = svg.replace(/<defs>[\s\S]*?<\/defs>/, '').trim()
|
||||||
|
|
||||||
|
// This logo is a wide wordmark (viewBox ~3360x576), not a square icon,
|
||||||
|
// so width must scale from its own aspect ratio instead of a fixed value.
|
||||||
|
const viewBox = svg.match(/viewBox="[\d.]+ [\d.]+ ([\d.]+) ([\d.]+)"/)
|
||||||
|
const width = viewBox ? Math.round(logoHeight * (Number(viewBox[1]) / Number(viewBox[2]))) : logoHeight
|
||||||
|
|
||||||
|
return svg.replace('<svg', `<svg width="${width}" height="${logoHeight}"`)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full h-full flex flex-col justify-between bg-[#0B0A0A] px-[80px] py-[60px] font-[Roboto]">
|
||||||
|
<!-- Same shape, colors and blur as the site's own :ellipsis component: a wide
|
||||||
|
flat oval filled with its diagonal blue/cyan gradient, then blurred. -->
|
||||||
|
<div class="absolute blur-3xl top-[80px] right-[50px] w-[900px] h-[360px] rounded-full bg-[linear-gradient(97.62deg,rgba(0,71,225,0.18)_2.27%,rgba(26,214,255,0.12)_65%,rgba(0,71,225,0.12)_98.48%)]" />
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col justify-center">
|
||||||
|
<p
|
||||||
|
v-if="headline"
|
||||||
|
:class="`uppercase text-[22px] font-bold m-0 mb-5 tracking-[0.05em] text-${primaryColor}-500`"
|
||||||
|
>
|
||||||
|
{{ headline }}
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
v-if="title"
|
||||||
|
class="m-0 mb-6 text-[50px] font-bold text-white leading-[1.1] w-full max-w-[900px] wrap-break-word"
|
||||||
|
>
|
||||||
|
{{ title?.slice(0, 60) }}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
v-if="description"
|
||||||
|
class="m-0 text-[28px] text-neutral-400 leading-[1.4] w-full max-w-[900px] wrap-break-word"
|
||||||
|
>
|
||||||
|
{{ description?.slice(0, 200) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<div
|
||||||
|
v-if="logoSvg"
|
||||||
|
class="h-[40px]"
|
||||||
|
v-html="logoSvg"
|
||||||
|
/>
|
||||||
|
<div v-else class="text-white text-[18px] font-normal rounded-lg px-5 py-2">
|
||||||
|
{{ siteName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const appConfig = useAppConfig()
|
||||||
|
const { forced: forcedColorMode } = useDocusColorMode()
|
||||||
|
|
||||||
|
const { isEnabled: isAssistantEnabled } = useAssistant()
|
||||||
|
const { isEnabled, locales } = useDocusI18n()
|
||||||
|
const { subNavigationMode } = useSubNavigation()
|
||||||
|
|
||||||
|
const links = computed(() => {
|
||||||
|
const list = []
|
||||||
|
if (appConfig.socials?.gitea) {
|
||||||
|
list.push({
|
||||||
|
'icon': 'i-simple-icons-gitea',
|
||||||
|
'to': appConfig.socials.gitea,
|
||||||
|
'target': '_blank',
|
||||||
|
'aria-label': 'Gitea',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (appConfig.github?.url) {
|
||||||
|
list.push({
|
||||||
|
'icon': 'i-simple-icons-github',
|
||||||
|
'to': appConfig.github.url,
|
||||||
|
'target': '_blank',
|
||||||
|
'aria-label': 'GitHub',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UHeader
|
||||||
|
:ui="{ center: 'flex-1' }"
|
||||||
|
:class="{ 'flex flex-col': subNavigationMode === 'header' }"
|
||||||
|
>
|
||||||
|
<AppHeaderCenter />
|
||||||
|
|
||||||
|
<template #left>
|
||||||
|
<AppHeaderLeft />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #right>
|
||||||
|
<AppHeaderCTA />
|
||||||
|
|
||||||
|
<template v-if="isAssistantEnabled">
|
||||||
|
<AssistantChat />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="isEnabled && locales.length > 1">
|
||||||
|
<ClientOnly>
|
||||||
|
<LanguageSelect />
|
||||||
|
|
||||||
|
<template #fallback>
|
||||||
|
<div class="h-8 w-8 animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-md" />
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<USeparator
|
||||||
|
orientation="vertical"
|
||||||
|
class="h-8"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UContentSearchButton class="lg:hidden" />
|
||||||
|
|
||||||
|
<ClientOnly v-if="!forcedColorMode">
|
||||||
|
<UColorModeButton />
|
||||||
|
|
||||||
|
<template #fallback>
|
||||||
|
<div class="h-8 w-8 animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-md" />
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<template v-if="links?.length">
|
||||||
|
<UButton
|
||||||
|
v-for="(link, index) of links"
|
||||||
|
:key="index"
|
||||||
|
v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #toggle="{ open, toggle }">
|
||||||
|
<IconMenuToggle
|
||||||
|
:open="open"
|
||||||
|
class="lg:hidden"
|
||||||
|
@click="toggle"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<AppHeaderBody />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template
|
||||||
|
v-if="subNavigationMode === 'header'"
|
||||||
|
#bottom
|
||||||
|
>
|
||||||
|
<AppHeaderBottom />
|
||||||
|
</template>
|
||||||
|
</UHeader>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { sections } = useSubNavigation()
|
||||||
|
|
||||||
|
const navMenuVariants = useUIConfig('navigationMenu')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<template v-if="sections.length">
|
||||||
|
<!-- Empty spacer: keeps the header's flex-1 center slot from collapsing
|
||||||
|
while the real menu below is absolutely positioned so it can match
|
||||||
|
the article's content-column width instead of this slot's width. -->
|
||||||
|
<div class="hidden lg:block w-full" />
|
||||||
|
|
||||||
|
<UContainer class="absolute inset-x-0 inset-y-0 hidden lg:flex items-center pointer-events-none">
|
||||||
|
<!-- Mirrors the docs page's actual layout: an outer 10-col grid (left
|
||||||
|
doc-tree sidebar = col-span-2) containing a second, nested 10-col
|
||||||
|
grid for the article body (right TOC sidebar = col-span-2 of that
|
||||||
|
inner grid). Matching both levels is what lines this menu up with
|
||||||
|
the real content column instead of a naive single-level fraction. -->
|
||||||
|
<div class="grid grid-cols-10 gap-10 w-full">
|
||||||
|
<div class="col-span-8 col-start-3 grid grid-cols-10 gap-10">
|
||||||
|
<div class="col-span-8 col-start-1 pointer-events-auto">
|
||||||
|
<UNavigationMenu
|
||||||
|
:items="sections"
|
||||||
|
:highlight="navMenuVariants.highlight ?? true"
|
||||||
|
:highlight-color="navMenuVariants.highlightColor"
|
||||||
|
:variant="navMenuVariants.variant ?? 'pill'"
|
||||||
|
:color="navMenuVariants.color"
|
||||||
|
class="-mx-[10px] w-[calc(100%+20px)] [&>div]:w-full"
|
||||||
|
:ui="{ list: 'w-full justify-between', item: 'py-0' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UContentSearchButton
|
||||||
|
v-else
|
||||||
|
:collapsed="false"
|
||||||
|
class="w-full"
|
||||||
|
variant="soft"
|
||||||
|
:ui="{
|
||||||
|
leadingIcon: 'size-4 mx-0.5',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
width?: string
|
||||||
|
height?: string
|
||||||
|
zIndex?: string
|
||||||
|
top?: string
|
||||||
|
left?: string
|
||||||
|
right?: string
|
||||||
|
blur?: string
|
||||||
|
colors?: string[]
|
||||||
|
}>(), {
|
||||||
|
width: '10rem',
|
||||||
|
height: '10rem',
|
||||||
|
zIndex: '10',
|
||||||
|
top: '0',
|
||||||
|
left: 'auto',
|
||||||
|
right: 'auto',
|
||||||
|
blur: '50px',
|
||||||
|
colors: () => ['rgba(0, 71, 225, 0.34)', 'rgba(26, 214, 255, 0.22)', 'rgba(0, 71, 225, 0.22)'],
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute w-full"
|
||||||
|
:style="{
|
||||||
|
top,
|
||||||
|
insetInlineStart: left,
|
||||||
|
insetInlineEnd: right,
|
||||||
|
zIndex,
|
||||||
|
maxWidth: width,
|
||||||
|
height,
|
||||||
|
filter: `blur(${blur})`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full h-full"
|
||||||
|
:style="{
|
||||||
|
background: `linear-gradient(97.62deg, ${colors[0]} 2.27%, ${colors[1]} 65%, ${colors[2]} 98.48%)`,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
export type FileTreeEntry = string | Record<string, FileTreeEntry[]>
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
tree: FileTreeEntry
|
||||||
|
label?: string
|
||||||
|
collapsed?: boolean
|
||||||
|
}>(), {
|
||||||
|
label: 'Folder structure',
|
||||||
|
collapsed: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// `collapsed` only sets the initial state; the header click below then
|
||||||
|
// toggles this independently of the prop.
|
||||||
|
const isOpen = ref(!props.collapsed)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="not-prose my-5 rounded-lg overflow-hidden bg-elevated/50 ring ring-default divide-y divide-default">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1.5 w-full px-4 py-3 text-muted hover:text-default hover:bg-elevated/50 transition-colors cursor-pointer"
|
||||||
|
@click="isOpen = !isOpen"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-folder-tree" class="size-4 shrink-0" />
|
||||||
|
<span class="text-sm/6">{{ label }}</span>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-chevron-down"
|
||||||
|
class="size-4 shrink-0 ms-auto transition-transform"
|
||||||
|
:class="isOpen ? '' : '-rotate-90'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<ul v-show="isOpen" class="text-sm leading-relaxed px-2 py-2 list-none">
|
||||||
|
<FileTreeNode :entry="tree" root />
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useClipboard } from '@vueuse/core'
|
||||||
|
import codeIconTheme from '#build/ui/prose/code-icon'
|
||||||
|
import type { FileTreeEntry } from './FileTree.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
entry: FileTreeEntry
|
||||||
|
root?: boolean
|
||||||
|
parentPath?: string
|
||||||
|
isLast?: boolean
|
||||||
|
}>(), {
|
||||||
|
root: false,
|
||||||
|
parentPath: '',
|
||||||
|
isLast: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
function splitComment(raw: string) {
|
||||||
|
const index = raw.indexOf(' #')
|
||||||
|
if (index === -1) return { text: raw, comment: undefined as string | undefined }
|
||||||
|
return { text: raw.slice(0, index).trimEnd(), comment: raw.slice(index + 2).trim() }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawEntry = computed(() => typeof props.entry === 'object' ? Object.keys(props.entry)[0] : props.entry as string)
|
||||||
|
const parsed = computed(() => splitComment(rawEntry.value))
|
||||||
|
|
||||||
|
const isFolder = computed(() => typeof props.entry === 'object' || parsed.value.text.endsWith('/'))
|
||||||
|
|
||||||
|
// Strip a trailing "/" marker, except when it's the whole name: that's the
|
||||||
|
// filesystem root itself, written as a bare "/".
|
||||||
|
const name = computed(() => {
|
||||||
|
const text = parsed.value.text
|
||||||
|
return text.length > 1 && text.endsWith('/') ? text.slice(0, -1) : text
|
||||||
|
})
|
||||||
|
const comment = computed(() => parsed.value.comment)
|
||||||
|
|
||||||
|
const children = computed<FileTreeEntry[]>(() => {
|
||||||
|
if (typeof props.entry !== 'object') return []
|
||||||
|
return Object.values(props.entry)[0] || []
|
||||||
|
})
|
||||||
|
|
||||||
|
// The root's own name is "/" already; every other node just appends its
|
||||||
|
// name to its parent's path, without doubling that leading slash.
|
||||||
|
const fullPath = computed(() => {
|
||||||
|
if (props.root) return name.value
|
||||||
|
return props.parentPath === '/' ? `/${name.value}` : `${props.parentPath}/${name.value}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const { copy, copied } = useClipboard({ source: fullPath })
|
||||||
|
|
||||||
|
function onClick() {
|
||||||
|
copy()
|
||||||
|
}
|
||||||
|
|
||||||
|
const appConfig = useAppConfig()
|
||||||
|
|
||||||
|
// Same lookup order as Nuxt UI's own CodeIcon.vue (exact filename match,
|
||||||
|
// then extension, then the vscode-icons fallback), so a file gets the same
|
||||||
|
// icon here as it would in a labeled code fence.
|
||||||
|
const icon = computed(() => {
|
||||||
|
if (isFolder.value) return 'i-lucide-folder'
|
||||||
|
|
||||||
|
const filename = name.value
|
||||||
|
const icons = { ...codeIconTheme, ...(appConfig.ui?.prose?.codeIcon || {}) } as Record<string, string>
|
||||||
|
const extension = filename.includes('.') ? filename.split('.').pop() : undefined
|
||||||
|
|
||||||
|
return icons[filename.toLowerCase()]
|
||||||
|
?? (extension && icons[extension])
|
||||||
|
?? (extension && `i-vscode-icons-file-type-${extension}`)
|
||||||
|
?? 'i-lucide-file'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li class="relative" :class="root ? '' : 'ps-3'">
|
||||||
|
<!-- Not the last child: a plain full-height guide line, since it needs
|
||||||
|
to keep going for the next sibling below it anyway. This is a direct
|
||||||
|
child of the LI (not the row span below, like the other guides),
|
||||||
|
so its own "start-0" lines up with the row span's "-start-1.5":
|
||||||
|
the row span sits 1.5 further in (its "-mx-1.5), so its own offset
|
||||||
|
needs those same 1.5 taken back out to land on the same column. -->
|
||||||
|
<span v-if="!root && !isLast" class="absolute start-0 top-0 bottom-0 w-px bg-white/20" />
|
||||||
|
<span
|
||||||
|
class="group flex items-center gap-1.5 py-1 px-1.5 -mx-1.5 rounded-md relative hover:bg-elevated/50 transition-colors cursor-pointer"
|
||||||
|
title="Copy path"
|
||||||
|
@click="onClick"
|
||||||
|
>
|
||||||
|
<!-- Last child: one rounded corner (border-inline-start + border-block-end
|
||||||
|
on a single box) instead of a separate vertical + horizontal stroke,
|
||||||
|
so the join is one clean curve rather than two translucent strokes
|
||||||
|
stacking into a visibly brighter square where they cross. Sized off
|
||||||
|
this row's own box (top to its vertical center) instead of a guessed
|
||||||
|
pixel height, so it stays in sync if the row's height ever changes. -->
|
||||||
|
<span
|
||||||
|
v-if="!root && isLast"
|
||||||
|
class="absolute -start-1.5 top-0 bottom-1/2 w-3 border-s border-b border-white/20 rounded-es-md"
|
||||||
|
/>
|
||||||
|
<!-- Not the last child: just the branch into the icon — the vertical
|
||||||
|
guide itself is the LI-level line above, offset a hair to the
|
||||||
|
right of it so the two strokes sit side by side instead of
|
||||||
|
overlapping. -->
|
||||||
|
<span
|
||||||
|
v-if="!root && !isLast"
|
||||||
|
class="absolute -start-[5px] top-1/2 -translate-y-1/2 w-3 h-px bg-white/20"
|
||||||
|
/>
|
||||||
|
<!-- Bridges the gap between this icon's own bottom edge and where its
|
||||||
|
children's guide lines start (right at this row's bottom edge,
|
||||||
|
which is exactly where the child <ul> begins), so the line reads
|
||||||
|
as coming out of the folder icon rather than piercing through it
|
||||||
|
or starting in mid-air. Starts at the row's center plus half the
|
||||||
|
icon's own height (size-4 = 16px), so it clears the icon glyph
|
||||||
|
regardless of the row's actual height. -->
|
||||||
|
<span
|
||||||
|
v-if="isFolder && children.length"
|
||||||
|
class="absolute start-3.5 top-[calc(50%+8px)] bottom-0 w-px bg-white/20"
|
||||||
|
/>
|
||||||
|
<UIcon
|
||||||
|
:name="icon"
|
||||||
|
class="shrink-0 size-4"
|
||||||
|
:class="isFolder ? 'text-[var(--ui-primary)]' : 'text-[var(--ui-text-dimmed)]'"
|
||||||
|
/>
|
||||||
|
<span>{{ name }}</span>
|
||||||
|
<span v-if="comment" class="text-xs text-muted italic">{{ comment }}</span>
|
||||||
|
<UIcon
|
||||||
|
:name="copied ? 'i-lucide-check' : 'i-lucide-copy'"
|
||||||
|
class="size-3.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<ul v-if="children.length" class="ms-2 ps-0 list-none">
|
||||||
|
<FileTreeNode
|
||||||
|
v-for="(child, i) in children"
|
||||||
|
:key="i"
|
||||||
|
:entry="child"
|
||||||
|
:parent-path="fullPath"
|
||||||
|
:is-last="i === children.length - 1"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { sidebarNavigation } = useSubNavigation()
|
||||||
|
|
||||||
|
const contentNavVariants = useUIConfig('contentNavigation')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UContentNavigation
|
||||||
|
:collapsible="true"
|
||||||
|
:default-open="false"
|
||||||
|
:highlight="contentNavVariants.highlight ?? true"
|
||||||
|
:highlight-color="contentNavVariants.highlightColor"
|
||||||
|
:variant="contentNavVariants.variant ?? 'link'"
|
||||||
|
:color="contentNavVariants.color"
|
||||||
|
:navigation="sidebarNavigation"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { subNavigationMode, sections } = useSubNavigation()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="subNavigationMode === 'aside'"
|
||||||
|
class="mb-2"
|
||||||
|
>
|
||||||
|
<UPageAnchors :links="sections" />
|
||||||
|
<USeparator
|
||||||
|
type="dashed"
|
||||||
|
class="my-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<UContentSearchButton
|
||||||
|
:collapsed="false"
|
||||||
|
class="w-full"
|
||||||
|
variant="soft"
|
||||||
|
:ui="{
|
||||||
|
leadingIcon: 'size-4 mx-0.5',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useClipboard } from '@vueuse/core'
|
||||||
|
import { joinURL, withTrailingSlash } from 'ufo'
|
||||||
|
import { useRuntimeConfig } from '#imports'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const toast = useToast()
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
const appBaseURL = runtimeConfig.app?.baseURL || '/'
|
||||||
|
const mcpRoute = (runtimeConfig.public.mcp as { route?: string } | undefined)?.route || '/mcp'
|
||||||
|
|
||||||
|
const { copy, copied } = useClipboard()
|
||||||
|
const { t } = useDocusI18n()
|
||||||
|
|
||||||
|
const markdownLink = computed(() => `${window?.location?.origin}${withTrailingSlash(appBaseURL)}raw${route.path}.md`)
|
||||||
|
const mcpServerUrl = computed(() => `${window?.location?.origin}${joinURL(appBaseURL, mcpRoute)}`)
|
||||||
|
const mcpDeeplink = computed(() => `${window?.location?.origin}${joinURL(appBaseURL, mcpRoute, 'deeplink')}`)
|
||||||
|
const items = computed(() => [
|
||||||
|
[{
|
||||||
|
label: t('docs.copy.link'),
|
||||||
|
icon: 'i-lucide-link',
|
||||||
|
onSelect() {
|
||||||
|
copy(markdownLink.value)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('docs.copy.view'),
|
||||||
|
icon: 'i-simple-icons:markdown',
|
||||||
|
target: '_blank',
|
||||||
|
to: markdownLink.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('docs.copy.gpt'),
|
||||||
|
icon: 'i-simple-icons:openai',
|
||||||
|
target: '_blank',
|
||||||
|
to: `https://chatgpt.com/?hints=search&q=${encodeURIComponent(`Read ${markdownLink.value} so I can ask questions about it.`)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('docs.copy.claude'),
|
||||||
|
icon: 'i-simple-icons:anthropic',
|
||||||
|
target: '_blank',
|
||||||
|
to: `https://claude.ai/new?q=${encodeURIComponent(`Read ${markdownLink.value} so I can ask questions about it.`)}`,
|
||||||
|
}],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: 'Copy MCP Server URL',
|
||||||
|
icon: 'i-lucide-link',
|
||||||
|
onSelect() {
|
||||||
|
copy(mcpServerUrl.value)
|
||||||
|
toast.add({
|
||||||
|
title: 'Copied to clipboard',
|
||||||
|
icon: 'i-lucide-check-circle',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Add MCP Server',
|
||||||
|
icon: 'i-simple-icons:cursor',
|
||||||
|
target: '_blank',
|
||||||
|
to: mcpDeeplink.value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
|
async function copyPage() {
|
||||||
|
const page = await $fetch<string>(`/raw${route.path}.md`)
|
||||||
|
copy(page)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UFieldGroup size="sm">
|
||||||
|
<UButton
|
||||||
|
:label="t('docs.copy.page')"
|
||||||
|
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
class="bg-[rgba(12,13,12,0.8)] hover:bg-[rgba(18,17,16,0.9)] border border-[#121110]"
|
||||||
|
:ui="{
|
||||||
|
leadingIcon: 'text-neutral size-3.5',
|
||||||
|
}"
|
||||||
|
@click="copyPage"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UDropdownMenu
|
||||||
|
size="sm"
|
||||||
|
:items="items"
|
||||||
|
:content="{
|
||||||
|
align: 'end',
|
||||||
|
side: 'bottom',
|
||||||
|
sideOffset: 8,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-chevron-down"
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
class="bg-[rgba(12,13,12,0.8)] hover:bg-[rgba(18,17,16,0.9)] border border-[#121110] border-l-[#121110]"
|
||||||
|
/>
|
||||||
|
</UDropdownMenu>
|
||||||
|
</UFieldGroup>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAppConfig } from '#imports'
|
||||||
|
import Callout from '#ui/components/prose/Callout.vue'
|
||||||
|
|
||||||
|
// Set `::caution{icon=""}` in the markdown to hide the default icon for
|
||||||
|
// that one instance (useful when the body already starts with its own
|
||||||
|
// emoji).
|
||||||
|
const props = withDefaults(defineProps<{ icon?: string }>(), {
|
||||||
|
icon: undefined,
|
||||||
|
})
|
||||||
|
const appConfig = useAppConfig()
|
||||||
|
const icon = computed(() => props.icon !== undefined ? props.icon : appConfig.ui.icons.caution)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Callout
|
||||||
|
color="error"
|
||||||
|
:icon="icon"
|
||||||
|
>
|
||||||
|
<slot mdc-unwrap="p" />
|
||||||
|
</Callout>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAppConfig } from '#imports'
|
||||||
|
import Callout from '#ui/components/prose/Callout.vue'
|
||||||
|
|
||||||
|
// Set `::note{icon=""}` in the markdown to hide the default icon for that
|
||||||
|
// one instance (useful when the body already starts with its own emoji).
|
||||||
|
const props = withDefaults(defineProps<{ icon?: string }>(), {
|
||||||
|
icon: undefined,
|
||||||
|
})
|
||||||
|
const appConfig = useAppConfig()
|
||||||
|
const icon = computed(() => props.icon !== undefined ? props.icon : appConfig.ui.icons.info)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Callout
|
||||||
|
color="info"
|
||||||
|
:icon="icon"
|
||||||
|
>
|
||||||
|
<slot mdc-unwrap="p" />
|
||||||
|
</Callout>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAppConfig } from '#imports'
|
||||||
|
import Callout from '#ui/components/prose/Callout.vue'
|
||||||
|
|
||||||
|
// Set `::tip{icon=""}` in the markdown to hide the default icon for that
|
||||||
|
// one instance (useful when the body already starts with its own emoji).
|
||||||
|
const props = withDefaults(defineProps<{ icon?: string }>(), {
|
||||||
|
icon: undefined,
|
||||||
|
})
|
||||||
|
const appConfig = useAppConfig()
|
||||||
|
const icon = computed(() => props.icon !== undefined ? props.icon : appConfig.ui.icons.tip)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Callout
|
||||||
|
color="success"
|
||||||
|
:icon="icon"
|
||||||
|
>
|
||||||
|
<slot mdc-unwrap="p" />
|
||||||
|
</Callout>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAppConfig } from '#imports'
|
||||||
|
import Callout from '#ui/components/prose/Callout.vue'
|
||||||
|
|
||||||
|
// Set `::warning{icon=""}` in the markdown to hide the default icon for
|
||||||
|
// that one instance (useful when the body already starts with its own
|
||||||
|
// emoji).
|
||||||
|
const props = withDefaults(defineProps<{ icon?: string }>(), {
|
||||||
|
icon: undefined,
|
||||||
|
})
|
||||||
|
const appConfig = useAppConfig()
|
||||||
|
const icon = computed(() => props.icon !== undefined ? props.icon : appConfig.ui.icons.warning)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Callout
|
||||||
|
color="warning"
|
||||||
|
:icon="icon"
|
||||||
|
>
|
||||||
|
<slot mdc-unwrap="p" />
|
||||||
|
</Callout>
|
||||||
|
</template>
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
|
|
||||||
|
|
||||||
export default defineContentConfig({
|
|
||||||
collections: {
|
|
||||||
landing: defineCollection({
|
|
||||||
type: 'page',
|
|
||||||
source: {
|
|
||||||
// @ts-expect-error __DOCS_DIR__ is not defined
|
|
||||||
cwd: globalThis.__DOCS_DIR__,
|
|
||||||
include: 'index.md',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
docs: defineCollection({
|
|
||||||
type: 'page',
|
|
||||||
source: {
|
|
||||||
// @ts-expect-error __DOCS_DIR__ is not defined
|
|
||||||
cwd: globalThis.__DOCS_DIR__,
|
|
||||||
include: '**',
|
|
||||||
exclude: ['index.md'],
|
|
||||||
},
|
|
||||||
schema: z.object({
|
|
||||||
links: z.array(z.object({
|
|
||||||
label: z.string(),
|
|
||||||
icon: z.string(),
|
|
||||||
to: z.string(),
|
|
||||||
target: z.string().optional(),
|
|
||||||
})).optional(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { defineNuxtModule } from '@nuxt/kit'
|
|
||||||
import { defu } from 'defu'
|
|
||||||
import { inferSiteURL, getPackageJsonMetadata } from '../app/utils/meta'
|
|
||||||
import { getGitBranch, getGitEnv, getLocalGitInfo } from '../app/utils/git'
|
|
||||||
|
|
||||||
export default defineNuxtModule({
|
|
||||||
meta: {
|
|
||||||
name: 'default-configs',
|
|
||||||
},
|
|
||||||
async setup(_options, nuxt) {
|
|
||||||
const dir = nuxt.options.rootDir
|
|
||||||
const url = inferSiteURL()
|
|
||||||
const meta = await getPackageJsonMetadata(dir)
|
|
||||||
const gitInfo = await getLocalGitInfo(dir) || getGitEnv()
|
|
||||||
const siteName = nuxt.options?.site?.name || meta.name || gitInfo?.name || ''
|
|
||||||
|
|
||||||
nuxt.options.llms = defu(nuxt.options.llms, {
|
|
||||||
domain: url,
|
|
||||||
title: siteName,
|
|
||||||
description: meta.description || '',
|
|
||||||
full: {
|
|
||||||
title: siteName,
|
|
||||||
description: meta.description || '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
nuxt.options.site = defu(nuxt.options.site, {
|
|
||||||
url,
|
|
||||||
name: siteName,
|
|
||||||
debug: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
nuxt.options.appConfig.header = defu(nuxt.options.appConfig.header, {
|
|
||||||
title: siteName,
|
|
||||||
})
|
|
||||||
|
|
||||||
nuxt.options.appConfig.seo = defu(nuxt.options.appConfig.seo, {
|
|
||||||
titleTemplate: `%s - ${siteName}`,
|
|
||||||
title: siteName,
|
|
||||||
description: meta.description || '',
|
|
||||||
})
|
|
||||||
|
|
||||||
nuxt.options.appConfig.github = defu(nuxt.options.appConfig.github, {
|
|
||||||
owner: gitInfo?.owner,
|
|
||||||
name: gitInfo?.name,
|
|
||||||
url: gitInfo?.url,
|
|
||||||
branch: getGitBranch(),
|
|
||||||
})
|
|
||||||
|
|
||||||
nuxt.options.appConfig.toc = defu(nuxt.options.appConfig.toc, {
|
|
||||||
title: 'On this page',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { extendViteConfig } from '@nuxt/kit'
|
|
||||||
|
|
||||||
// Flag enabled when developing docs theme
|
|
||||||
const dev = !!process.env.NUXT_DOCS_DEV
|
|
||||||
|
|
||||||
export default defineNuxtConfig({
|
|
||||||
modules: [
|
|
||||||
'@nuxt/ui-pro',
|
|
||||||
'@nuxt/content',
|
|
||||||
'@nuxt/image',
|
|
||||||
'@nuxtjs/robots',
|
|
||||||
'nuxt-og-image',
|
|
||||||
'nuxt-llms',
|
|
||||||
() => {
|
|
||||||
// Update @nuxt/content optimizeDeps options
|
|
||||||
extendViteConfig((config) => {
|
|
||||||
config.optimizeDeps ||= {}
|
|
||||||
config.optimizeDeps.include ||= []
|
|
||||||
config.optimizeDeps.include.push('@nuxt/content > slugify')
|
|
||||||
config.optimizeDeps.include = config.optimizeDeps.include
|
|
||||||
.map(id => id.replace(/^@nuxt\/content > /, 'docus > @nuxt/content > '))
|
|
||||||
})
|
|
||||||
},
|
|
||||||
],
|
|
||||||
devtools: {
|
|
||||||
enabled: dev,
|
|
||||||
},
|
|
||||||
css: ['../app/assets/css/main.css'],
|
|
||||||
content: {
|
|
||||||
build: {
|
|
||||||
markdown: {
|
|
||||||
highlight: {
|
|
||||||
langs: ['bash', 'diff', 'json', 'js', 'ts', 'html', 'css', 'vue', 'shell', 'mdc', 'md', 'yaml'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
nitro: {
|
|
||||||
prerender: {
|
|
||||||
routes: ['/'],
|
|
||||||
crawlLinks: true,
|
|
||||||
failOnError: false,
|
|
||||||
autoSubfolderIndex: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
icon: {
|
|
||||||
provider: 'iconify',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import { field, group } from '@nuxt/content/preview'
|
|
||||||
|
|
||||||
export default defineNuxtSchema({
|
|
||||||
appConfig: {
|
|
||||||
ui: group({
|
|
||||||
title: 'UI',
|
|
||||||
description: 'UI Customization.',
|
|
||||||
icon: 'i-lucide-palette',
|
|
||||||
fields: {
|
|
||||||
colors: group({
|
|
||||||
title: 'Colors',
|
|
||||||
description: 'Manage main colors of your application',
|
|
||||||
icon: 'i-lucide-palette',
|
|
||||||
fields: {
|
|
||||||
primary: field({
|
|
||||||
type: 'string',
|
|
||||||
title: 'Primary',
|
|
||||||
description: 'Primary color of your UI.',
|
|
||||||
icon: 'i-lucide-palette',
|
|
||||||
default: 'green',
|
|
||||||
required: ['red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose'],
|
|
||||||
}),
|
|
||||||
neutral: field({
|
|
||||||
type: 'string',
|
|
||||||
title: 'Neutral',
|
|
||||||
description: 'Neutral color of your UI.',
|
|
||||||
icon: 'i-lucide-palette',
|
|
||||||
default: 'slate',
|
|
||||||
required: ['slate', 'gray', 'zinc', 'neutral', 'stone'],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
icons: group({
|
|
||||||
title: 'Icons',
|
|
||||||
description: 'Manage icons used in the application.',
|
|
||||||
icon: 'i-lucide-settings',
|
|
||||||
fields: {
|
|
||||||
search: field({
|
|
||||||
type: 'icon',
|
|
||||||
title: 'Search Bar',
|
|
||||||
description: 'Icon to display in the search bar.',
|
|
||||||
icon: 'i-lucide-search',
|
|
||||||
default: 'i-lucide-search',
|
|
||||||
}),
|
|
||||||
dark: field({
|
|
||||||
type: 'icon',
|
|
||||||
title: 'Dark mode',
|
|
||||||
description: 'Icon of color mode button for dark mode.',
|
|
||||||
icon: 'i-lucide-moon',
|
|
||||||
default: 'i-lucide-moon',
|
|
||||||
}),
|
|
||||||
light: field({
|
|
||||||
type: 'icon',
|
|
||||||
title: 'Light mode',
|
|
||||||
description: 'Icon of color mode button for light mode.',
|
|
||||||
icon: 'i-lucide-sun',
|
|
||||||
default: 'i-lucide-sun',
|
|
||||||
}),
|
|
||||||
external: field({
|
|
||||||
type: 'icon',
|
|
||||||
title: 'External Link',
|
|
||||||
description: 'Icon for external link.',
|
|
||||||
icon: 'i-lucide-external-link',
|
|
||||||
default: 'i-lucide-external-link',
|
|
||||||
}),
|
|
||||||
chevron: field({
|
|
||||||
type: 'icon',
|
|
||||||
title: 'Chevron',
|
|
||||||
description: 'Icon for chevron.',
|
|
||||||
icon: 'i-lucide-chevron-down',
|
|
||||||
default: 'i-lucide-chevron-down',
|
|
||||||
}),
|
|
||||||
hash: field({
|
|
||||||
type: 'icon',
|
|
||||||
title: 'Hash',
|
|
||||||
description: 'Icon for hash anchors.',
|
|
||||||
icon: 'i-lucide-hash',
|
|
||||||
default: 'i-lucide-hash',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
seo: group({
|
|
||||||
title: 'SEO',
|
|
||||||
description: 'SEO configuration.',
|
|
||||||
icon: 'i-lucide-search',
|
|
||||||
fields: {
|
|
||||||
title: field({
|
|
||||||
type: 'string',
|
|
||||||
title: 'Title',
|
|
||||||
description: 'Title to display in the header.',
|
|
||||||
icon: 'i-lucide-type',
|
|
||||||
default: '',
|
|
||||||
}),
|
|
||||||
description: field({
|
|
||||||
type: 'string',
|
|
||||||
title: 'Description',
|
|
||||||
description: 'Description to display in the header.',
|
|
||||||
icon: 'i-lucide-type',
|
|
||||||
default: '',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
header: group({
|
|
||||||
title: 'Header',
|
|
||||||
description: 'Header configuration.',
|
|
||||||
icon: 'i-lucide-layout',
|
|
||||||
fields: {
|
|
||||||
title: field({
|
|
||||||
type: 'string',
|
|
||||||
title: 'Title',
|
|
||||||
description: 'Title to display in the header.',
|
|
||||||
icon: 'i-lucide-type',
|
|
||||||
default: '',
|
|
||||||
}),
|
|
||||||
logo: group({
|
|
||||||
title: 'Logo',
|
|
||||||
description: 'Header logo configuration.',
|
|
||||||
icon: 'i-lucide-image',
|
|
||||||
fields: {
|
|
||||||
light: field({
|
|
||||||
type: 'media',
|
|
||||||
title: 'Light Mode Logo',
|
|
||||||
description: 'Pick an image from your gallery.',
|
|
||||||
icon: 'i-lucide-sun',
|
|
||||||
default: '',
|
|
||||||
}),
|
|
||||||
dark: field({
|
|
||||||
type: 'media',
|
|
||||||
title: 'Dark Mode Logo',
|
|
||||||
description: 'Pick an image from your gallery.',
|
|
||||||
icon: 'i-lucide-moon',
|
|
||||||
default: '',
|
|
||||||
}),
|
|
||||||
alt: field({
|
|
||||||
type: 'string',
|
|
||||||
title: 'Alt',
|
|
||||||
description: 'Alt to display for accessibility.',
|
|
||||||
icon: 'i-lucide-text',
|
|
||||||
default: '',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
socials: field({
|
|
||||||
type: 'object',
|
|
||||||
title: 'Social Networks',
|
|
||||||
description: 'Social links configuration.',
|
|
||||||
icon: 'i-lucide-network',
|
|
||||||
default: {},
|
|
||||||
}),
|
|
||||||
toc: group({
|
|
||||||
title: 'Table of contents',
|
|
||||||
description: 'TOC configuration.',
|
|
||||||
icon: 'i-lucide-list',
|
|
||||||
fields: {
|
|
||||||
title: field({
|
|
||||||
type: 'string',
|
|
||||||
title: 'Title',
|
|
||||||
description: 'Title of the table of contents.',
|
|
||||||
icon: 'i-lucide-heading',
|
|
||||||
default: 'On this page',
|
|
||||||
}),
|
|
||||||
bottom: group({
|
|
||||||
title: 'Bottom',
|
|
||||||
description: 'Bottom section of the table of contents.',
|
|
||||||
icon: 'i-lucide-list',
|
|
||||||
fields: {
|
|
||||||
title: field({
|
|
||||||
type: 'string',
|
|
||||||
title: 'Title',
|
|
||||||
description: 'Title of the bottom section.',
|
|
||||||
icon: 'i-lucide-heading',
|
|
||||||
default: 'Community',
|
|
||||||
}),
|
|
||||||
links: field({
|
|
||||||
type: 'array',
|
|
||||||
title: 'Links',
|
|
||||||
description: 'Links to display in the bottom section.',
|
|
||||||
icon: 'i-lucide-link',
|
|
||||||
default: [],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
github: group({
|
|
||||||
title: 'GitHub',
|
|
||||||
description: 'GitHub configuration.',
|
|
||||||
icon: 'i-simple-icons-github',
|
|
||||||
fields: {
|
|
||||||
url: field({
|
|
||||||
type: 'string',
|
|
||||||
title: 'URL',
|
|
||||||
description: 'GitHub URL.',
|
|
||||||
icon: 'i-simple-icons-github',
|
|
||||||
default: '',
|
|
||||||
}),
|
|
||||||
branch: field({
|
|
||||||
type: 'string',
|
|
||||||
title: 'Branch',
|
|
||||||
description: 'GitHub branch.',
|
|
||||||
icon: 'i-lucide-git-branch',
|
|
||||||
default: 'main',
|
|
||||||
}),
|
|
||||||
rootDir: field({
|
|
||||||
type: 'string',
|
|
||||||
title: 'Root Directory',
|
|
||||||
description: 'Root directory of the GitHub repository.',
|
|
||||||
icon: 'i-lucide-folder',
|
|
||||||
default: '',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { kebabCase } from 'scule'
|
||||||
|
import type { ContentNavigationItem, Collections, DocsCollectionItem } from '@nuxt/content'
|
||||||
|
import { findPageHeadline } from '@nuxt/content/utils'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'docs',
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { locale, isEnabled, t } = useDocusI18n()
|
||||||
|
const { isOpen } = useAssistant()
|
||||||
|
const appConfig = useAppConfig()
|
||||||
|
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
|
||||||
|
const collectionName = computed(() => isEnabled.value ? `docs_${locale.value}` : 'docs')
|
||||||
|
|
||||||
|
const [{ data: page }, { data: surround }] = await Promise.all([
|
||||||
|
useAsyncData(kebabCase(route.path), () => queryCollection(collectionName.value as keyof Collections).path(route.path).first() as Promise<DocsCollectionItem>),
|
||||||
|
useAsyncData(`${kebabCase(route.path)}-surround`, () => {
|
||||||
|
return queryCollectionItemSurroundings(collectionName.value as keyof Collections, route.path, {
|
||||||
|
fields: ['description'],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!page.value) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = page.value.seo?.title || page.value.title
|
||||||
|
const description = page.value.seo?.description || page.value.description
|
||||||
|
|
||||||
|
const headline = ref(findPageHeadline(navigation?.value, page.value?.path))
|
||||||
|
const breadcrumbs = computed(() => findPageBreadcrumbs(navigation?.value, page.value?.path || ''))
|
||||||
|
|
||||||
|
// Set `hideHeader: true` in a page's frontmatter to skip the title/description
|
||||||
|
// block entirely (e.g. for a page that builds its own custom layout).
|
||||||
|
const hideHeader = computed(() => !!(page.value as unknown as Record<string, unknown>)?.hideHeader)
|
||||||
|
|
||||||
|
// Set `hideCopyPage: true` in a page's frontmatter to hide the "Copy page"
|
||||||
|
// dropdown (copy link / view as markdown / open in ChatGPT / Claude).
|
||||||
|
const hideCopyPage = computed(() => !!(page.value as unknown as Record<string, unknown>)?.hideCopyPage)
|
||||||
|
|
||||||
|
// Set `hideToc: true` in a page's frontmatter to hide the right-hand
|
||||||
|
// "On this page" table-of-contents sidebar.
|
||||||
|
const hideToc = computed(() => !!(page.value as unknown as Record<string, unknown>)?.hideToc)
|
||||||
|
|
||||||
|
useSeo({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
type: 'article',
|
||||||
|
modifiedAt: (page.value as unknown as Record<string, unknown>).modifiedAt as string | undefined,
|
||||||
|
breadcrumbs,
|
||||||
|
})
|
||||||
|
watch(() => navigation?.value, () => {
|
||||||
|
headline.value = findPageHeadline(navigation?.value, page.value?.path) || headline.value
|
||||||
|
})
|
||||||
|
|
||||||
|
defineOgImage('Docs', {
|
||||||
|
headline: headline.value,
|
||||||
|
title: title?.slice(0, 60),
|
||||||
|
description: formatOgDescription(title, description),
|
||||||
|
})
|
||||||
|
|
||||||
|
const github = computed(() => appConfig.github ? appConfig.github : null)
|
||||||
|
const giteaUrl = computed(() => appConfig.socials?.gitea as string | undefined)
|
||||||
|
|
||||||
|
// "Edit this page" points at Gitea (git.djeex.fr), not the GitHub mirror.
|
||||||
|
// Gitea's edit route is `/{owner}/{repo}/_edit/{branch}/{path}` (note the
|
||||||
|
// leading underscore — different from GitHub's `/edit/{branch}/{path}`).
|
||||||
|
const editLink = computed(() => {
|
||||||
|
if (!giteaUrl.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
giteaUrl.value,
|
||||||
|
'_edit',
|
||||||
|
'main',
|
||||||
|
'content',
|
||||||
|
`${page.value?.stem}.${page.value?.extension}`,
|
||||||
|
].filter(Boolean).join('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
const contributors = computed(() => (page.value as unknown as Record<string, unknown>)?.contributors as string[] | undefined)
|
||||||
|
|
||||||
|
const historyLink = computed(() => {
|
||||||
|
if (!giteaUrl.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
giteaUrl.value,
|
||||||
|
'commits',
|
||||||
|
'branch',
|
||||||
|
'main',
|
||||||
|
'content',
|
||||||
|
`${page.value?.stem}.${page.value?.extension}`,
|
||||||
|
].filter(Boolean).join('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add the page path to the prerender list
|
||||||
|
addPrerenderPath(`/raw${route.path}.md`)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UPage
|
||||||
|
v-if="page"
|
||||||
|
class="relative"
|
||||||
|
:ui="isOpen ? { center: 'lg:col-span-10' } : undefined"
|
||||||
|
>
|
||||||
|
<UPageHeader
|
||||||
|
v-if="!hideHeader"
|
||||||
|
:title="page.title"
|
||||||
|
:description="page.description"
|
||||||
|
:headline="headline"
|
||||||
|
:ui="{
|
||||||
|
wrapper: 'flex-row items-center flex-wrap justify-between',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #links>
|
||||||
|
<UButton
|
||||||
|
v-for="(link, index) in (page as DocsCollectionItem).links"
|
||||||
|
:key="index"
|
||||||
|
size="sm"
|
||||||
|
v-bind="link"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DocsPageHeaderLinks v-if="!hideCopyPage" />
|
||||||
|
</template>
|
||||||
|
</UPageHeader>
|
||||||
|
|
||||||
|
<UPageBody>
|
||||||
|
<ContentRenderer
|
||||||
|
v-if="page"
|
||||||
|
:value="page"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<USeparator v-if="giteaUrl || github">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 text-sm text-muted max-[420px]:flex-col"
|
||||||
|
>
|
||||||
|
<UButton
|
||||||
|
v-if="editLink"
|
||||||
|
variant="link"
|
||||||
|
color="neutral"
|
||||||
|
:to="editLink"
|
||||||
|
target="_blank"
|
||||||
|
icon="i-lucide-pen"
|
||||||
|
:ui="{ leadingIcon: 'size-4' }"
|
||||||
|
>
|
||||||
|
{{ t('docs.edit') }}
|
||||||
|
</UButton>
|
||||||
|
<template v-if="giteaUrl">
|
||||||
|
<span>{{ t('common.or') }}</span>
|
||||||
|
<UButton
|
||||||
|
variant="link"
|
||||||
|
color="neutral"
|
||||||
|
:to="`${giteaUrl}/issues/new`"
|
||||||
|
target="_blank"
|
||||||
|
icon="i-lucide-alert-circle"
|
||||||
|
:ui="{ leadingIcon: 'size-4' }"
|
||||||
|
>
|
||||||
|
{{ t('docs.report') }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</USeparator>
|
||||||
|
<div
|
||||||
|
v-if="contributors?.length"
|
||||||
|
class="flex items-center gap-2 text-sm text-muted"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-users"
|
||||||
|
class="size-4 shrink-0"
|
||||||
|
/>
|
||||||
|
<span>{{ locale === 'fr' ? (contributors.length > 1 ? 'Contributeurs' : 'Contributeur') : (contributors.length > 1 ? 'Contributors' : 'Contributor') }}:</span>
|
||||||
|
<ULink
|
||||||
|
v-if="historyLink"
|
||||||
|
:to="historyLink"
|
||||||
|
target="_blank"
|
||||||
|
class="text-highlighted hover:underline"
|
||||||
|
>
|
||||||
|
{{ contributors.join(', ') }}
|
||||||
|
</ULink>
|
||||||
|
<span v-else>{{ contributors.join(', ') }}</span>
|
||||||
|
</div>
|
||||||
|
<UContentSurround :surround="surround" />
|
||||||
|
</UPageBody>
|
||||||
|
|
||||||
|
<template
|
||||||
|
v-if="!isOpen && !hideToc"
|
||||||
|
#right
|
||||||
|
>
|
||||||
|
<DocsAsideRight
|
||||||
|
:page="page"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</UPage>
|
||||||
|
</template>
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { withLeadingSlash } from 'ufo'
|
|
||||||
import { stringify } from 'minimark/stringify'
|
|
||||||
import { queryCollection } from '@nuxt/content/nitro'
|
|
||||||
|
|
||||||
export default eventHandler(async (event) => {
|
|
||||||
const slug = getRouterParams(event)['slug.md']
|
|
||||||
if (!slug?.endsWith('.md')) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = withLeadingSlash(slug.replace('.md', ''))
|
|
||||||
const page = await queryCollection(event, 'docs').path(path).first()
|
|
||||||
if (!page) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add title and description to the top of the page if missing
|
|
||||||
if (page.body.value[0]?.[0] !== 'h1') {
|
|
||||||
page.body.value.unshift(['blockquote', {}, page.description])
|
|
||||||
page.body.value.unshift(['h1', {}, page.title])
|
|
||||||
}
|
|
||||||
|
|
||||||
setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8')
|
|
||||||
return stringify({ ...page.body, type: 'minimark' }, { format: 'markdown/html' })
|
|
||||||
})
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./.nuxt/tsconfig.json"
|
|
||||||
}
|
|
||||||
-95
@@ -1,95 +0,0 @@
|
|||||||
import { resolve } from 'node:path'
|
|
||||||
import { defineCommand, runMain } from 'citty'
|
|
||||||
import type { ArgsDef } from 'citty'
|
|
||||||
import { getNuxtConfig } from './setup'
|
|
||||||
import type { CLIOptions } from './types'
|
|
||||||
|
|
||||||
export function createCLI(opts: CLIOptions) {
|
|
||||||
const sharedArgs: ArgsDef = {
|
|
||||||
dir: {
|
|
||||||
type: 'positional',
|
|
||||||
description: 'Docs directory',
|
|
||||||
required: true,
|
|
||||||
default: '.',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const init = defineCommand({
|
|
||||||
meta: {
|
|
||||||
name: 'init',
|
|
||||||
description: 'Initialize a fresh Docus project',
|
|
||||||
},
|
|
||||||
args: { ...sharedArgs },
|
|
||||||
async setup({ args }) {
|
|
||||||
const dir = resolve(args.dir as string)
|
|
||||||
|
|
||||||
const { runCommand } = await import('nuxi')
|
|
||||||
await runCommand('init', [dir, '-t', 'gh:nuxtlabs/docus/.starter', dir])
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const dev = defineCommand({
|
|
||||||
meta: {
|
|
||||||
name: 'dev',
|
|
||||||
description: 'Start docs in development mode',
|
|
||||||
},
|
|
||||||
args: { ...sharedArgs },
|
|
||||||
async setup({ args }) {
|
|
||||||
const dir = resolve(args.dir as string)
|
|
||||||
const nuxtConfig = await getNuxtConfig(dir, {
|
|
||||||
...opts.setup,
|
|
||||||
dev: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { runCommand } = await import('nuxi')
|
|
||||||
await runCommand('dev', [dir, '--no-fork', '--port', process.env.PORT || '4000'], { overrides: nuxtConfig })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const prepare = defineCommand({
|
|
||||||
meta: {
|
|
||||||
name: 'prepare',
|
|
||||||
description: 'Prepare docs for development or production',
|
|
||||||
},
|
|
||||||
args: { ...sharedArgs },
|
|
||||||
async setup({ args }) {
|
|
||||||
const dir = resolve(args.dir as string)
|
|
||||||
const nuxtConfig = await getNuxtConfig(dir, opts.setup)
|
|
||||||
|
|
||||||
const { runCommand } = await import('nuxi')
|
|
||||||
await runCommand('prepare', [dir], { overrides: nuxtConfig })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const build = defineCommand({
|
|
||||||
meta: {
|
|
||||||
name: 'build',
|
|
||||||
description: 'Build docs for production',
|
|
||||||
},
|
|
||||||
args: { ...sharedArgs },
|
|
||||||
async setup({ args }) {
|
|
||||||
const dir = resolve(args.dir as string)
|
|
||||||
const nuxtConfig = await getNuxtConfig(dir, opts.setup)
|
|
||||||
|
|
||||||
const { runCommand } = await import('nuxi')
|
|
||||||
await runCommand('build', [dir], { overrides: nuxtConfig })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const main = defineCommand({
|
|
||||||
meta: {
|
|
||||||
name: opts.name,
|
|
||||||
description: opts.description,
|
|
||||||
},
|
|
||||||
subCommands: {
|
|
||||||
init,
|
|
||||||
dev,
|
|
||||||
prepare,
|
|
||||||
build,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
runMain: () => runMain(main),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import * as dotenv from 'dotenv'
|
|
||||||
import { createCLI } from './cli'
|
|
||||||
|
|
||||||
dotenv.config()
|
|
||||||
|
|
||||||
const cli = createCLI({
|
|
||||||
name: 'Docus',
|
|
||||||
description: 'Docus Docs CLI',
|
|
||||||
setup: {
|
|
||||||
defaults: {},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
cli.runMain()
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
import { resolve } from 'node:path'
|
|
||||||
import type { NuxtConfig } from 'nuxt/config'
|
|
||||||
import type { DocsOptions } from './types'
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
const __DOCS_DIR__: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const appDir = fileURLToPath(new URL('../app', import.meta.url))
|
|
||||||
|
|
||||||
const pkgDir = fileURLToPath(new URL('..', import.meta.url))
|
|
||||||
|
|
||||||
export async function getNuxtConfig(dir: string, _opts: DocsOptions = {}) {
|
|
||||||
const fixLayers = (_, nuxt) => {
|
|
||||||
const hasDocsDir = nuxt.options._layers.some(layer => layer.cwd === dir)
|
|
||||||
if (!hasDocsDir) {
|
|
||||||
nuxt.options._layers.unshift({
|
|
||||||
cwd: dir,
|
|
||||||
config: {
|
|
||||||
rootDir: dir,
|
|
||||||
srcDir: dir,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-expect-error __DOCS_DIR__ is not defined
|
|
||||||
global.__DOCS_DIR__ = resolve(dir, 'content')
|
|
||||||
|
|
||||||
// Prepare loadNuxt overrides
|
|
||||||
return {
|
|
||||||
compatibilityDate: '2025-06-17',
|
|
||||||
extends: [appDir],
|
|
||||||
modulesDir: [resolve(pkgDir, 'node_modules'), resolve(appDir, 'node_modules')],
|
|
||||||
modules: [fixLayers],
|
|
||||||
} as NuxtConfig
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
export interface CLIOptions {
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
setup: DocsOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DocsOptions {
|
|
||||||
dev?: boolean
|
|
||||||
defaults?: {
|
|
||||||
// Module name
|
|
||||||
name?: string
|
|
||||||
// Module description
|
|
||||||
description?: string
|
|
||||||
// Docs directory
|
|
||||||
dir?: string
|
|
||||||
// Website URL
|
|
||||||
url?: string
|
|
||||||
// GitHub repository
|
|
||||||
github?: string
|
|
||||||
// GitHub branch
|
|
||||||
branch?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { DefinedCollection } from '@nuxt/content'
|
||||||
|
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
|
||||||
|
import { useNuxt } from '@nuxt/kit'
|
||||||
|
import { joinURL } from 'ufo'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
|
||||||
|
const { options } = useNuxt()
|
||||||
|
const cwd = joinURL(options.rootDir, 'content')
|
||||||
|
const locales = options.i18n?.locales
|
||||||
|
|
||||||
|
// Same checks as docus's own content.config.ts (node_modules/docus/utils/pages.ts,
|
||||||
|
// not a published package export, so reimplemented here rather than imported).
|
||||||
|
function landingPageExists(rootDir: string): boolean {
|
||||||
|
return existsSync(joinURL(rootDir, 'app', 'pages', 'index.vue'))
|
||||||
|
}
|
||||||
|
function docsFolderExists(rootDir: string, locale?: string): boolean {
|
||||||
|
return existsSync(locale ? joinURL(rootDir, 'content', locale, 'docs') : joinURL(rootDir, 'content', 'docs'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLandingPage = landingPageExists(options.rootDir)
|
||||||
|
const hasDocsFolder = docsFolderExists(options.rootDir)
|
||||||
|
|
||||||
|
// Same as docus's own createDocsSchema(), plus the two custom per-page
|
||||||
|
// frontmatter toggles used by app/pages/[[lang]]/[...slug].vue. Nuxt
|
||||||
|
// Content's Zod schema silently strips any frontmatter key that isn't
|
||||||
|
// declared here, which is why hideHeader/hideCopyPage did nothing until
|
||||||
|
// this schema was extended.
|
||||||
|
const createDocsSchema = () => z.object({
|
||||||
|
links: z.array(z.object({
|
||||||
|
label: z.string(),
|
||||||
|
icon: z.string(),
|
||||||
|
to: z.string(),
|
||||||
|
target: z.string().optional(),
|
||||||
|
})).optional(),
|
||||||
|
hideHeader: z.boolean().optional(),
|
||||||
|
hideCopyPage: z.boolean().optional(),
|
||||||
|
hideToc: z.boolean().optional(),
|
||||||
|
contributors: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
let collections: Record<string, DefinedCollection>
|
||||||
|
|
||||||
|
if (locales && Array.isArray(locales)) {
|
||||||
|
collections = {}
|
||||||
|
for (const locale of locales) {
|
||||||
|
const code = (typeof locale === 'string' ? locale : locale.code).replace('-', '_')
|
||||||
|
const hasLocaleDocs = docsFolderExists(options.rootDir, code)
|
||||||
|
|
||||||
|
if (!hasLandingPage) {
|
||||||
|
collections[`landing_${code}`] = defineCollection({
|
||||||
|
type: 'page',
|
||||||
|
source: {
|
||||||
|
cwd,
|
||||||
|
include: `${code}/index.md`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
collections[`docs_${code}`] = defineCollection({
|
||||||
|
type: 'page',
|
||||||
|
source: {
|
||||||
|
cwd,
|
||||||
|
include: hasLocaleDocs ? `${code}/docs/**` : `${code}/**/*`,
|
||||||
|
prefix: hasLocaleDocs ? `/${code}/docs` : `/${code}`,
|
||||||
|
exclude: [`${code}/index.md`],
|
||||||
|
},
|
||||||
|
schema: createDocsSchema(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
collections = {
|
||||||
|
docs: defineCollection({
|
||||||
|
type: 'page',
|
||||||
|
source: {
|
||||||
|
cwd,
|
||||||
|
include: hasDocsFolder ? 'docs/**' : '**',
|
||||||
|
prefix: hasDocsFolder ? '/docs' : '/',
|
||||||
|
exclude: ['index.md'],
|
||||||
|
},
|
||||||
|
schema: createDocsSchema(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasLandingPage) {
|
||||||
|
collections.landing = defineCollection({
|
||||||
|
type: 'page',
|
||||||
|
source: {
|
||||||
|
cwd,
|
||||||
|
include: 'index.md',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineContentConfig({ collections })
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
title: About
|
||||||
|
icon: i-noto-star
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
title: Welcome
|
||||||
|
description: Introduction to Docudjeex, a personal homelab documentation site covering self-hosted services, Debian, and Docker infrastructure.
|
||||||
|
navigation:
|
||||||
|
icon: i-lucide-home
|
||||||
|
hideHeader: true
|
||||||
|
hideCopyPage: true
|
||||||
|
hideToc: true
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
:ellipsis{right=0px width=75% blur=150px zIndex=60}
|
||||||
|
# docu[·]{style="color: #1ad6ff"}what?
|
||||||
|
|
||||||
|
__Docu[·]{style="color: #1ad6ff"}djeex__ is a site containing the documentation of my personal servers, originally created to easily keep track of my configurations and commands.
|
||||||
|
My infrastructure is built around the Debian 13 + Docker combo, making exporting and deployment simpler.
|
||||||
|
Special thanks to __Nipah__, __Xenio__, __KevOut__ and others for their patience and support. The simple idea of writing this documentation would not exist without them.
|
||||||
|
|
||||||
|
## About the documentation
|
||||||
|
|
||||||
|
The documentation provided here is experimental and shared in a spirit of open knowledge and experience. It is not intended to build production-grade or industrialized infrastructure. It may contain mistakes and/or approximations.
|
||||||
|
|
||||||
|
Naturally, this documentation should only be used within a strictly legal framework.
|
||||||
|
|
||||||
|
### Available or Upcoming Documentation
|
||||||
|
|
||||||
|
:::div{class="relative"}
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px}
|
||||||
|
:::
|
||||||
|
|
||||||
|
::card-group
|
||||||
|
::card{icon="i-noto-open-book" title="General" to="/general/networking/nat"}
|
||||||
|
Networking, storage, and hardware basics
|
||||||
|
::
|
||||||
|
|
||||||
|
::card{icon="i-noto-microscope" title="Serveex" to="/serveex/introduction"}
|
||||||
|
Step-by-step Homelab Deployment Guide
|
||||||
|
::
|
||||||
|
|
||||||
|
::card{icon="i-noto-computer-disk" title="Stockeex"}
|
||||||
|
*(coming soon)* Build your own home NAS to store your data and media
|
||||||
|
::
|
||||||
|
|
||||||
|
::card{icon="i-noto-test-tube" title="My nonsense" to="/nonsense/python/nvidia-stock-bot"}
|
||||||
|
Personal scripts and side projects
|
||||||
|
::
|
||||||
|
|
||||||
|
::card{icon="i-noto-recycling-symbol" title="Recycled" to="/recycled/deprecated/wireguard-14"}
|
||||||
|
Deprecated pages, kept for archive
|
||||||
|
::
|
||||||
|
::
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
title: General
|
||||||
|
icon: i-noto-open-book
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
title: General
|
||||||
|
description: General homelab knowledge, networking, storage, and hardware fundamentals that apply beyond any single self-hosted app.
|
||||||
|
navigation:
|
||||||
|
icon: i-lucide-bookmark
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px zIndex=60}
|
||||||
|
## Homelab Fundamentals
|
||||||
|
|
||||||
|
This section covers the general knowledge that [Serveex](/serveex/introduction) itself relies on but doesn't re-explain every time: how networking actually works at home, how to choose and set up storage, and what hardware to run it all on. Read it once, then link back to it from any app-specific guide.
|
||||||
|
|
||||||
|
### Networking
|
||||||
|
|
||||||
|
:::div{class="relative"}
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px}
|
||||||
|
:::
|
||||||
|
|
||||||
|
::card-group
|
||||||
|
::card{icon="i-lucide-router" title="NAT & DHCP" to="/general/networking/nat"}
|
||||||
|
Port forwarding and fixed DHCP leases on your router
|
||||||
|
::
|
||||||
|
|
||||||
|
::card{icon="i-lucide-globe" title="DNS Zone" to="/general/networking/dns"}
|
||||||
|
Reading and editing a domain's DNS zone
|
||||||
|
::
|
||||||
|
|
||||||
|
::card{icon="i-lucide-folder-sync" title="Samba" to="/general/networking/samba"}
|
||||||
|
Share folders over the local network
|
||||||
|
::
|
||||||
|
::
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
:::div{class="relative"}
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px}
|
||||||
|
:::
|
||||||
|
|
||||||
|
::card-group
|
||||||
|
::card{icon="i-lucide-database" title="RAID" to="/general/storage/raid"}
|
||||||
|
Redundant disk arrays, hardware vs software
|
||||||
|
::
|
||||||
|
|
||||||
|
::card{icon="i-lucide-layers" title="ZFS" to="/general/storage/zfs"}
|
||||||
|
Snapshots, checksums, and built-in redundancy
|
||||||
|
::
|
||||||
|
::
|
||||||
|
|
||||||
|
### Hardware
|
||||||
|
|
||||||
|
:::div{class="relative"}
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px}
|
||||||
|
:::
|
||||||
|
|
||||||
|
::card-group
|
||||||
|
::card{icon="i-lucide-cpu" title="The Basics" to="/general/hardware/basics"}
|
||||||
|
CPUs, RAM, storage, and form factors
|
||||||
|
::
|
||||||
|
|
||||||
|
::card{icon="i-lucide-network" title="Network" to="/general/hardware/network"}
|
||||||
|
Switches, NICs, and cabling
|
||||||
|
::
|
||||||
|
|
||||||
|
::card{icon="i-lucide-hard-drive" title="The ProloNAS" to="/general/hardware/prolonas"}
|
||||||
|
A budget N100 home server build
|
||||||
|
::
|
||||||
|
::
|
||||||
|
|
||||||
|
### Linux tips for dummies
|
||||||
|
|
||||||
|
:::div{class="relative"}
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px}
|
||||||
|
:::
|
||||||
|
|
||||||
|
::card-group
|
||||||
|
::card{icon="i-lucide-terminal" title="Command Line Basics" to="/general/linux/cli-basics"}
|
||||||
|
How a command is built, and the ones you'll actually use
|
||||||
|
::
|
||||||
|
|
||||||
|
::card{icon="i-lucide-folder-tree" title="Folders and Partitions" to="/general/linux/filesystem"}
|
||||||
|
What lives where on Debian, and the habits that keep it tidy
|
||||||
|
::
|
||||||
|
|
||||||
|
::card{icon="i-lucide-wrench" title="Handy CLI Tools" to="/general/linux/handy-tools"}
|
||||||
|
Terminal tools worth installing, and how to set them up
|
||||||
|
::
|
||||||
|
::
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
title: Networking
|
||||||
|
icon: i-lucide-network
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
title: NAT & DHCP
|
||||||
|
description: Learn how NAT, port forwarding, and DHCP work on a home router. Configure fixed IP leases and understand how to expose local services.
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px zIndex=60}
|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## What is a "port"?
|
||||||
|
|
||||||
|
Ports are different channels through which your router sends and receives data. This allows multiple services to run simultaneously.
|
||||||
|
When it receives data through a port, your router forwards that data to the machine that:
|
||||||
|
|
||||||
|
- either initiated the request,
|
||||||
|
- or is configured to receive data on a specific port.
|
||||||
|
|
||||||
|
Your router has over 65,000 ports available.
|
||||||
|
|
||||||
|
Some programs and applications are designed to use specific ports. For example, when your network sends data from an HTML page, the router receives it through port 80 (non-secure) or port `443` (secure via SSL).
|
||||||
|
|
||||||
|
So, your router acts as a data dispatcher between the internet and your local machines.
|
||||||
|
|
||||||
|
## Port Forwarding
|
||||||
|
|
||||||
|
Forwarding a `port` means setting a rule that specifies which `source` can send data to which `port` on your router, which will then forward it to a specific `port` on a specific `machine`. The `sources` and `destination machine` are identified by their IP addresses.
|
||||||
|
|
||||||
|
| Variable | Description | Example |
|
||||||
|
|------------------------|---------------------------------------------------------|-------------------------|
|
||||||
|
| `source machine` | IP of the source machine (from the internet) | `All`<br>`123.45.67.89` |
|
||||||
|
| `source port` | Incoming port on the router | `443` |
|
||||||
|
| `destination port` | Port on the destination machine | `3000` |
|
||||||
|
| `destination machine` | IP of the target machine (on your local network) | `192.168.1.50` |
|
||||||
|
|
||||||
|
According to the table:
|
||||||
|
If we remove `All` and keep the IP `123.45.67.89`, all traffic from this IP sent to port `443` on your router will be forwarded to port `3000` on the local IP `192.168.1.50`.
|
||||||
|
|
||||||
|
If we remove the IP and keep `All`, then all traffic from the internet on port `443` will be redirected to port `3000` on `192.168.1.50`.
|
||||||
|
|
||||||
|
This is useful when you have a server that must be accessible from the internet. For instance, a website uses port `80` (non-secure) or `443` (SSL-secured).
|
||||||
|
To make the website accessible, you'll configure your router to redirect the domain request to your local server.
|
||||||
|
Assume your service runs on port `3000` locally (`http://192.168.1.50:3000`), you would redirect all traffic from port `443` on the router to port `3000` on the local server.
|
||||||
|
|
||||||
|
::warning{to="/serveex/core/swag"}
|
||||||
|
|
||||||
|
__Warning:__ If you have multiple services to expose like `subdomain1.mydomain.com` and `subdomain2.mydomain.com`, your router cannot differentiate requests and forward to different ports.
|
||||||
|
You must use a **Reverse Proxy** to route traffic based on the request.
|
||||||
|
::
|
||||||
|
|
||||||
|
## DHCP
|
||||||
|
|
||||||
|
Every time a device connects to your local network, your router assigns it an IP address using DHCP rules.
|
||||||
|
This IP is randomly selected from a predefined pool.
|
||||||
|
At every device reboot, the IP may change, which is problematic if you're forwarding ports, as the target IP may no longer be valid.
|
||||||
|
|
||||||
|
To avoid this, use your router's DHCP server to assign a static IP address.
|
||||||
|
|
||||||
|
Each device has a physical "MAC address".
|
||||||
|
To assign a fixed IP, you must know your device's MAC address (visible in your router when it's connected), and assign it a static IP.
|
||||||
|
This is called a "static DHCP lease."
|
||||||
|
|
||||||
|
That way, your machine's IP never changes and your port forwarding rules remain effective.
|
||||||
|
|
||||||
|
| Variable | Description | Example |
|
||||||
|
|---------------|----------------------------------|---------------------|
|
||||||
|
| `IP` | Fixed local IP to assign | `192.168.1.50` |
|
||||||
|
| `MAC Address` | Physical address of the device | `5E:FF:56:A2:AF:15` |
|
||||||
|
|
||||||
|
For more information, refer to your router's documentation.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
title: DNS Zone
|
||||||
|
description: Understand how DNS works, how to read and edit a DNS zone, and how to configure domain names for your self-hosted services.
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px zIndex=60}
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
When you browse a website or use an app, requests are made to one or more domains to fetch content for the page. Your device doesn't know the IP addresses of these servers, so it contacts a _name server_ (Domain Name Server), which responds with the most up-to-date IP address for the domain being requested.
|
||||||
|
|
||||||
|
The DNS zone is like a registry with signposts that direct your requests to the correct destination.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## The DNS Zone
|
||||||
|
|
||||||
|
When you purchase a domain from a registrar (Cloudflare, OVH, etc.), the registrar assigns you a DNS zone that you can customize.
|
||||||
|
|
||||||
|
You can enter _records_ into this DNS zone to direct requests properly. You can find [more information here](https://help.ovhcloud.com/csm/fr-dns-servers-general-information?id=kb_article_view&sysparm_article=KB0051661).
|
||||||
|
|
||||||
|
Example of a DNS zone for the domain `mydomain.com`:
|
||||||
|
|
||||||
|
```
|
||||||
|
$TTL 3600
|
||||||
|
@ IN SOA ns1.dns.me. dns.net. (2024051800 86400 3600 3600000 60)
|
||||||
|
IN NS ns1.dns.me.
|
||||||
|
IN NS ns2.dns.me.
|
||||||
|
IN A 203.0.113.10
|
||||||
|
www IN CNAME mydomain.com.
|
||||||
|
subdomain IN CNAME mydomain.com.
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example:
|
||||||
|
|
||||||
|
- `$TTL 3600` tells global name servers that the records are valid for 1 hour (after which they need to re-check).
|
||||||
|
- `IN SOA ns1.dns.me. dns.net. (...)` indicates `ns1.dns.me` as the primary DNS server, with refresh intervals.
|
||||||
|
- `IN NS` records define the authoritative name servers for the domain.
|
||||||
|
- `IN A 203.0.113.10` means `mydomain.com` points to IP `203.0.113.10`.
|
||||||
|
- `subdomain IN CNAME mydomain.com.` means `subdomain.mydomain.com` points to the same destination as `mydomain.com`.
|
||||||
|
|
||||||
|
So, if you want to point `mydomain.com` to your server, you can do it by adding an `A` record pointing to your server's public IP address.
|
||||||
|
|
||||||
|
::warning
|
||||||
|
|
||||||
|
__Warning:__ If your server is hosted at home:
|
||||||
|
- Your public IP is the one assigned to your home router. Make sure it's static, or configure [DDNS](https://aws.amazon.com/fr/what-is/dynamic-dns/).
|
||||||
|
- Make sure you've [set up port 443 forwarding to your server's listening port](/general/networking/nat).
|
||||||
|
::
|
||||||
|
|
||||||
|
If you're adding a subdomain that should also point to your server, use a `CNAME` record pointing to `mydomain.com`.
|
||||||
|
|
||||||
|
::note
|
||||||
|
|
||||||
|
__Why not use an `A` record for the subdomain?__ If your subdomain points to the same server as `mydomain.com`, it's better to use a `CNAME` record because if the server's IP changes, you won’t need to update the subdomain record.
|
||||||
|
::
|
||||||
|
|
||||||
|
Most registrars offer user-friendly interfaces to manage DNS records. Refer to your registrar’s documentation for specific instructions.
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
---
|
||||||
|
title: Samba
|
||||||
|
description: Set up Samba on Debian to share folders over your local network and access them from Windows, macOS, or Linux.
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px zIndex=60}
|
||||||
|
|
||||||
|
Samba is a free implementation of the SMB/CIFS protocol, allowing access to a folder located on a network drive. It can be configured on macOS, Windows, or Linux.
|
||||||
|
|
||||||
|
There are many tutorials for setting up Samba on Windows or on NAS systems like Synology, but here we focus on Debian.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Create and configure a Shared Network Folder
|
||||||
|
::note
|
||||||
|
|
||||||
|
In this example, we will share the `/video` folder from a remote machine called `remote-machine`. We will access this folder from a machine called `local-machine`. The user connecting to the network drive will be `sambauser`.
|
||||||
|
::
|
||||||
|
|
||||||
|
::steps{level="3"}
|
||||||
|
### Install Samba Server
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo apt update && sudo apt upgrade
|
||||||
|
sudo apt install samba smbclient cifs-utils
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create the `/video` Folder
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo mkdir /video
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configure the Share
|
||||||
|
|
||||||
|
Now, edit the file `/etc/samba/smb.conf`.
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo nano /etc/samba/smb.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
::tip{icon="" to="/serveex/files/file-browser-quantum"}
|
||||||
|
✨ __Tip:__ You can use **File Browser Quantum** to navigate and edit your files instead of using terminal commands.
|
||||||
|
::
|
||||||
|
|
||||||
|
Find the `workgroup` variable and name your workgroup (e.g., `workgroup = WORKGROUP`).
|
||||||
|
|
||||||
|
Then scroll to the end of the file and add the following configuration:
|
||||||
|
|
||||||
|
```properties [smb.conf]
|
||||||
|
[video]
|
||||||
|
comment = Video folder
|
||||||
|
path = /video
|
||||||
|
writable = yes
|
||||||
|
guest ok = no
|
||||||
|
valid users = @smbshare
|
||||||
|
force create mode = 770
|
||||||
|
force directory mode = 770
|
||||||
|
inherit permissions = yes
|
||||||
|
```
|
||||||
|
|
||||||
|
Press :kbd{value="Ctrl+O"}, then :kbd{value="Enter"} to save, and :kbd{value="Ctrl+X"} to exit.
|
||||||
|
### Done!
|
||||||
|
::
|
||||||
|
|
||||||
|
### Create a Samba User and Group
|
||||||
|
|
||||||
|
Since we're using a secured share, we need to create a user and group to access it remotely.
|
||||||
|
|
||||||
|
::steps{level="3"}
|
||||||
|
### Create the group
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo groupadd smbshare
|
||||||
|
```
|
||||||
|
|
||||||
|
Give the group control over the `/video` folder:
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo chgrp -R smbshare /video
|
||||||
|
```
|
||||||
|
|
||||||
|
Set inherited permissions:
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo chmod 2775 /video
|
||||||
|
```
|
||||||
|
### Create the user
|
||||||
|
|
||||||
|
Now add a no-login user: this user cannot log into the server but can access Samba.
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo useradd -M -s /sbin/nologin sambauser
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the user to the `smbshare` group:
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo usermod -aG smbshare sambauser
|
||||||
|
```
|
||||||
|
|
||||||
|
Set a Samba password:
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo smbpasswd -a sambauser
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enable the Samba account
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo smbpasswd -e sambauser
|
||||||
|
```
|
||||||
|
### Done!
|
||||||
|
::
|
||||||
|
|
||||||
|
## Accessing a Shared Folder
|
||||||
|
|
||||||
|
::steps{level="3"}
|
||||||
|
### Install Required Packages
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo apt update && sudo apt upgrade
|
||||||
|
sudo apt install cifs-utils
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create the Mount Destination
|
||||||
|
|
||||||
|
We will create a folder on our local machine where the remote `/video` folder will be mounted, e.g. `/mnt/video`.
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo mkdir /mnt/video
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prepare the .credentials File
|
||||||
|
|
||||||
|
To avoid typing our username and password every time, create a `.credentials` file storing the login info.
|
||||||
|
|
||||||
|
Create it in the `/smb` folder:
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo mkdir /smb
|
||||||
|
sudo nano /smb/.credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
Write:
|
||||||
|
|
||||||
|
```properties [.credentials]
|
||||||
|
username=sambauser
|
||||||
|
password=password
|
||||||
|
```
|
||||||
|
|
||||||
|
* `sambauser`: the user we created on the `remote-machine`
|
||||||
|
* `password`: the password set earlier
|
||||||
|
|
||||||
|
Press :kbd{value="Ctrl+O"}, then :kbd{value="Enter"} to save, and :kbd{value="Ctrl+X"} to exit.
|
||||||
|
|
||||||
|
Set proper file permissions:
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo chmod 600 /smb/.credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mount the Shared Folder
|
||||||
|
::warning
|
||||||
|
__Warning:__ If you're using ufw as firewall, you need to add a rule to allow your remote server to access to your share.
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo ufw allow from <your-remote-ip> to any app Samba
|
||||||
|
```
|
||||||
|
::
|
||||||
|
Now mount the folder:
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo mount -t cifs -o credentials=/smb/.credentials //remote-ip/video /mnt/video
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `remote-ip` with your `remote-machine`'s IP address.
|
||||||
|
|
||||||
|
Verify the mount:
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo mount -t cifs
|
||||||
|
```
|
||||||
|
|
||||||
|
You’ll see details confirming the mount is successful.
|
||||||
|
|
||||||
|
Now you can access the `/video` folder of the `remote-machine` from your `local-machine`!
|
||||||
|
|
||||||
|
### (Optional) Auto-mount on Boot
|
||||||
|
|
||||||
|
By default, shares aren't auto-mounted after reboot. To automate this, edit the `/etc/fstab` file.
|
||||||
|
|
||||||
|
First, back it up:
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo cp /etc/fstab /etc/fstab.bak
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the mount configuration line:
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
echo "//remote-ip/video /mnt/video cifs _netdev,nofail,credentials=/smb/.credentials,x-systemd.automount,x-systemd.device-timeout=15 0 0" | sudo tee -a /etc/fstab
|
||||||
|
```
|
||||||
|
|
||||||
|
Reboot the machine:
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
After rebooting, verify the mount:
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo mount -t cifs
|
||||||
|
```
|
||||||
|
### And done!
|
||||||
|
::
|
||||||
|
|
||||||
|
::tip
|
||||||
|
__Unmount the Shared Folder__
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo umount -t cifs /mnt/video
|
||||||
|
```
|
||||||
|
::
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
title: Storage
|
||||||
|
icon: i-lucide-hard-drive
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
---
|
||||||
|
title: RAID
|
||||||
|
description: Understand RAID concepts, hardware vs software, RAID levels, and how to set up redundant disk arrays for your homelab.
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px zIndex=60}
|
||||||
|
|
||||||
|
_Redundant Array of Independent Disks_
|
||||||
|
|
||||||
|
In computing, RAID (Redundant Array of Independent Disks) is a system that allows multiple hard drives to be combined to improve performance and/or reliability. It works by restructuring and distributing data blocks across the drives.
|
||||||
|
|
||||||
|
Originally, RAID systems were hardware-based, meaning a dedicated controller (a specific chip) managed data distribution and RAID operations. Today, most RAID systems (or their equivalents) are software-based. In fact, many software technologies can create RAID-like systems with features not available in hardware RAID, such as automatic repair (data scrubbing), snapshots, and more.
|
||||||
|
|
||||||
|
## Different Types of RAID
|
||||||
|
|
||||||
|
There are several types of RAID, each offering its own pros and cons. In general, RAID impacts the following five factors:
|
||||||
|
|
||||||
|
- Number of drives
|
||||||
|
- Total storage capacity
|
||||||
|
- Read speed
|
||||||
|
- Write speed
|
||||||
|
- Fault tolerance (resistance to hardware failure)
|
||||||
|
|
||||||
|
::warning
|
||||||
|
|
||||||
|
RAID is not a backup system but a service continuity system! It only allows hot-swapping of drives without interrupting your server or restoring from backup. You still need an external backup system.
|
||||||
|
::
|
||||||
|
|
||||||
|
### No RAID
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center;">
|
||||||
|
<img src="/img/global/no-raid.svg" alt="Disks with no RAID redundancy" style="max-width: 30%; max-height:230px; margin-right: 20px;">
|
||||||
|
<ul>
|
||||||
|
<li>Just your disks, without RAID. Data is stored disk by disk.</li>
|
||||||
|
<li>If you lose a disk, only its data is lost.</li>
|
||||||
|
<li>Total capacity is the sum of all disks.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
Use your disks without RAID when you're not afraid of data loss and can tolerate service interruptions between failure and backup restoration.
|
||||||
|
|
||||||
|
### RAID 0
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center;">
|
||||||
|
<img src="/img/global/raid0.svg" alt="RAID 0 striping data across disks" style="max-width: 30%; max-height:230px; margin-right: 20px;">
|
||||||
|
<ul>
|
||||||
|
<li>OS sees 1 drive.</li>
|
||||||
|
<li>Data is striped across all disks.</li>
|
||||||
|
<li>If you lose one disk, you lose all data.</li>
|
||||||
|
<li>High read and write performance (multiplied by number of disks).</li>
|
||||||
|
<li>Total capacity is the sum of all disks.</li>
|
||||||
|
<li>Minimum of 2 disks required.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
Use RAID 0 when you prioritize performance and are not concerned about data loss. Ideal for temporary, high-speed storage (video editing, AI workloads, etc). Not suitable for long-term storage, as one failure means total data loss.
|
||||||
|
|
||||||
|
### RAID 1
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center;">
|
||||||
|
<img src="/img/global/raid1.svg" alt="RAID 1 mirroring data across disks" style="max-width: 30%; max-height:230px; margin-right: 20px;">
|
||||||
|
<ul>
|
||||||
|
<li>OS sees 1 drive.</li>
|
||||||
|
<li>All disks contain identical data.</li>
|
||||||
|
<li>You can lose all but one disk.</li>
|
||||||
|
<li>Improved read speed (scales with number of disks).</li>
|
||||||
|
<li>Total capacity is equal to one disk (e.g., 2×10TB = 10TB).</li>
|
||||||
|
<li>Minimum of 2 disks required.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
Use RAID 1 for strong redundancy. Each disk contains all data, so performance remains unaffected during a failure. Once failed disks are replaced, data is quickly restored. However, usable storage is limited to one disk’s capacity, making it an expensive solution.
|
||||||
|
|
||||||
|
::tip{icon=""}
|
||||||
|
✨ __Tip:__ You can combine RAID 1 with other RAID types to create mirrored arrays.
|
||||||
|
::
|
||||||
|
|
||||||
|
### RAID 5
|
||||||
|
<p align="center">
|
||||||
|
<img src="/img/global/raid5.svg" alt="RAID 5 with distributed parity" style="max-width: 40%; margin-right: 20px;">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
- OS sees 1 drive.
|
||||||
|
- Data is striped with parity blocks for redundancy.
|
||||||
|
- You can lose 1 disk and recover data.
|
||||||
|
- Improved read speed (scales with number of disks).
|
||||||
|
- Total capacity is the sum of all disks minus one (e.g., 3×10TB = 20TB).
|
||||||
|
- Minimum of 3 disks (4 recommended to reduce capacity loss).
|
||||||
|
|
||||||
|
Use RAID 5 when you want reliable storage with 3 to 5 disks and minimal space loss. It tolerates one disk failure but may have degraded performance during recovery, which can take days.
|
||||||
|
|
||||||
|
### RAID 6
|
||||||
|
<p align="center">
|
||||||
|
<img src="/img/global/raid6.svg" alt="RAID 6 with double distributed parity" style="max-width: 50%; margin-right: 20px;">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
- OS sees 1 drive.
|
||||||
|
- Data is striped with dual parity blocks.
|
||||||
|
- You can lose 2 disks and still recover data.
|
||||||
|
- Improved read speed (scales with number of disks).
|
||||||
|
- Total capacity is the sum of all disks minus two (e.g., 4×10TB = 20TB).
|
||||||
|
- Minimum of 4 disks (6 recommended to minimize space loss).
|
||||||
|
|
||||||
|
Use RAID 6 in similar situations as RAID 5, especially with 6 or more disks. More disks mean higher failure risk. RAID 6 offers peace of mind by tolerating two simultaneous failures. There's nothing more frustrating than losing a second disk while the array is still rebuilding from replacing the first.
|
||||||
|
|
||||||
|
## Software RAID
|
||||||
|
(coming soon)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
title: ZFS
|
||||||
|
description: Introduction to ZFS, a combined file system and volume manager with snapshots, checksums, and built-in redundancy for reliable homelab storage.
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px zIndex=60}
|
||||||
|
|
||||||
|
|
||||||
|
ZFS is widely used in the world of servers, NAS systems (like FreeNAS / TrueNAS), virtualization, and even by tech-savvy individuals who want reliable storage. It is both a _file system_ (like NTFS for Windows, EXT4, FAT32, etc.) and a _volume manager_ (similar to LVM).
|
||||||
|
|
||||||
|
To put it simply:
|
||||||
|
|
||||||
|
- A **volume manager** organizes physical storage (like one or more hard drives).
|
||||||
|
- A **file system** organizes how data blocks are written, read, and deleted within those volumes.
|
||||||
|
|
||||||
|
ZFS goes far beyond traditional file systems in terms of performance and features.
|
||||||
|
Here’s what we’re most interested in:
|
||||||
|
|
||||||
|
- Its __snapshot management__ features, allowing you to quickly roll back in case of issues.
|
||||||
|
- Its support for disk groupings and [__RAID-like structures__](/general/storage/raid) (Z-Mirror, RAIDZ1, RAIDZ2, RAIDZ3).
|
||||||
|
- Its __automatic recovery of corrupted data__ (through scrubbing).
|
||||||
|
- Its performance, enhanced by RAM caching (ZFS ARC).
|
||||||
|
- Its robust error notifications and monitoring.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|

|
||||||
|
|
||||||
|
ZFS has a unique structure:
|
||||||
|
|
||||||
|
- **vdev** (virtual device): a group of physical or virtual disks.
|
||||||
|
- **zpool**: a collection of vdevs configured as a single storage pool. A zpool can contain multiple vdevs, but a vdev belongs to only one zpool.
|
||||||
|
- **dataset**: a logical data container within a zpool. Each dataset can have its own settings (compression, quotas, permissions, etc.).
|
||||||
|
|
||||||
|
There are several dataset types:
|
||||||
|
|
||||||
|
- **file system**: a standard ZFS filesystem, mounted without storage quotas.
|
||||||
|
- **zvol**: a "virtual disk" with a defined size, which you can format and partition as if it were a physical disk.
|
||||||
|
- **snapshot**: a frozen-in-time version of another dataset. Snapshots can be created manually or through backup tools. They can be mounted to browse data as it was at the snapshot time.
|
||||||
|
|
||||||
|
## Why ZFS over others?
|
||||||
|
### Data Integrity
|
||||||
|
|
||||||
|
ZFS continuously checks that your stored data hasn't become corrupted. Every block of data is associated with a checksum, allowing ZFS to detect even the smallest alteration. If corruption is found and a healthy copy exists elsewhere, ZFS can repair the data automatically.
|
||||||
|
|
||||||
|
### Built-in RAID
|
||||||
|
|
||||||
|
ZFS includes its own volume management system (vdevs). You can build a zpool using multiple disks, similar to traditional [RAID](/general/storage/raid) setups, but with more flexibility. For example:
|
||||||
|
|
||||||
|
- **Z-mirror** → equivalent to RAID 1
|
||||||
|
- **RAIDZ1** → equivalent to RAID 5 (tolerates 1 disk failure)
|
||||||
|
- **RAIDZ2** → equivalent to RAID 6 (tolerates 2 disk failures)
|
||||||
|
- **RAIDZ3** → tolerates up to 3 disk failures
|
||||||
|
|
||||||
|
ZFS handles all this natively: no external RAID software needed.
|
||||||
|
|
||||||
|
::note{to="/general/storage/raid"}
|
||||||
|
|
||||||
|
Check out the **article on RAID** to find the right solution for your needs.
|
||||||
|
::
|
||||||
|
|
||||||
|
### Snapshots and Clones
|
||||||
|
|
||||||
|
ZFS allows you to create snapshots: instantaneous images of a dataset's state. Snapshots take up minimal space and can be scheduled frequently. You can also create clones: writable copies of snapshots.
|
||||||
|
|
||||||
|
### Compression and Deduplication
|
||||||
|
|
||||||
|
ZFS can compress data on the fly (transparently to the user), saving disk space. It also supports deduplication (removing duplicate data), though this feature requires a lot of memory and is not recommended for all use cases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Now you know why ZFS is *the* file system to deploy on your NAS.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
title: Hardware
|
||||||
|
icon: i-lucide-server
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
---
|
||||||
|
title: The Basics
|
||||||
|
description: Overview of server hardware fundamentals. CPUs, RAM, storage, and form factors to understand before building your homelab.
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px zIndex=60}
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
A __server__ is essentially a computer dedicated to specific tasks, designed to remain accessible at all times. Structurally, it's not much different from a regular computer. Depending on its intended use, some components may vary. This article serves as a reference to help you understand the essential components of a server and how their roles adapt based on your needs.
|
||||||
|
|
||||||
|
## Motherboard
|
||||||
|
The __motherboard__ is the foundation of your machine. It's the component that connects all others together. It enables communication between components and interaction with peripherals (keyboard, mouse, etc.). Choose it based on your I/O (Input/Output) needs like USB ports, network ports, speed, etc., and ensure compatibility with the components you plan to install.
|
||||||
|
|
||||||
|
Key components connected to the motherboard:
|
||||||
|
|
||||||
|
- CPU
|
||||||
|
- RAM
|
||||||
|
- Storage (HDD and/or SSD)
|
||||||
|
- Optional dedicated GPU
|
||||||
|
|
||||||
|
Common consumer motherboard formats:
|
||||||
|
|
||||||
|
- E-ATX: largest
|
||||||
|
- ATX: standard
|
||||||
|
- Micro-ATX: smaller
|
||||||
|
- Mini-ITX: smallest
|
||||||
|
|
||||||
|
Larger boards generally offer more ports and features. Pre-built systems might use proprietary formats.
|
||||||
|
|
||||||
|
## CPU
|
||||||
|
<div style="display: flex; align-items: center;">
|
||||||
|
<img src="/img/global/cpu.svg" alt="CPU icon" style="max-width: 25%; max-height:230px; margin-right: 20px;">
|
||||||
|
<p>The <strong>CPU</strong> (Central Processing Unit) is the computer's calculator. It processes most software tasks. Modern CPUs have multiple cores, often with virtual threads, to better handle workloads. They need to be cooled using either an active cooler (with a fan) or a passive one (fanless), depending on power consumption (watts). Choose your CPU based on how you plan to use the server.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
::warning
|
||||||
|
|
||||||
|
__Caution:__ Ensure third-party coolers are compatible with the CPU socket and always apply thermal paste before installing the cooler.
|
||||||
|
::
|
||||||
|
|
||||||
|
Consider:
|
||||||
|
|
||||||
|
- Number of cores (more cores = better multitasking)
|
||||||
|
- Clock speed in GHz
|
||||||
|
- Power consumption in Watts
|
||||||
|
|
||||||
|
For low-power home servers or NAS (non-intensive computing), consider Intel N100/150 (4 cores) or N305/N355 (8 cores), efficient and low power (ideal for 24/7 uptime).
|
||||||
|
|
||||||
|
## RAM
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="/img/global/ram.svg" alt="RAM icon" style="max-width: 65%;">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
__RAM__ (Random Access Memory) is fast, temporary memory used by the CPU (and iGPU if applicable) for quick access during execution. It clears periodically and when the machine powers down. Better RAM = better CPU performance.
|
||||||
|
|
||||||
|
Comes as sticks installed on the motherboard. Varies by format and generation (currently DDR5).
|
||||||
|
|
||||||
|
## GPU
|
||||||
|
|
||||||
|
The __GPU__ (Graphics Processing Unit) handles graphical, video, and sometimes AI-related processing. Its main theoretical use is to display the image on your screen. In servers, it's useful for media centers (e.g. [Jellyfin](/serveex/media/jellyfin)) and for accelerating AI tasks like facial recognition or photo indexing (e.g. [Immich](/serveex/cloud/immich)).
|
||||||
|
|
||||||
|
Depending on the required performance, one can choose between a dedicated GPU with its own VRAM (a graphics card connected to a PCIe slot on the motherboard), or an iGPU, an integrated GPU built into the CPU (such as the N100/N150 or N305/N355), which uses the system’s shared RAM.
|
||||||
|
|
||||||
|
## HDD(s)
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="/img/global/hdd.svg" alt="Hard disk drive icon" style="max-width: 50%; margin-right: 20px;">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
An __HDD__ (Hard Disk Drive), or hard drive, is a component used to store data. It was once the standard storage device in computers. HDDs consist of one or more stacked platters and read/write heads, somewhat like a vinyl record player.
|
||||||
|
|
||||||
|
Today, HDDs can store enormous amounts of data (up to 30TB, or 30,000 gigabytes, for consumer models), but their read and write speeds are limited due to their mechanical nature. They are also bulky and heavy.
|
||||||
|
|
||||||
|
Generally, HDDs are best suited for storing data that doesn’t require frequent access or fast write speeds, such as media files (videos, photos), cloud drives, or archived data. They perform well in these scenarios and, most importantly, are significantly cheaper than SSDs for the same amount of storage.
|
||||||
|
|
||||||
|
::tip{icon="" to="/general/storage/raid"}
|
||||||
|
✨ __Tip:__ Use multiple HDDs in **RAID** to enhance performance and redundancy.
|
||||||
|
::
|
||||||
|
|
||||||
|
Comes in 3.5" and 2.5" formats; servers usually favor the more reliable 3.5".
|
||||||
|
|
||||||
|
## SSD(s)
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="/img/global/nvme.svg" alt="NVMe SSD icon" style="max-width: 50%; margin-right: 20px;">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
An __SSD__ (Solid State Drive) is a small circuit board with memory chips soldered onto it, used to store information. Unlike RAM, these chips retain data even when not powered, meaning the information is preserved after a reboot. SSDs are generally used as the main storage medium for your server.
|
||||||
|
|
||||||
|
Unlike HDDs, SSDs have no moving parts, are highly compact, and most importantly, are extremely fast, offering speeds of several gigabytes per second for high-performance models.
|
||||||
|
|
||||||
|
SSDs come in various formats, but today the preferred choice is the M.2 NVMe version, as it is the smallest, fastest, and has become the standard on modern motherboards.
|
||||||
|
|
||||||
|
However, SSDs are significantly more expensive than hard drives for the same storage capacity. Typically, the operating system (OS) is installed on the SSD to ensure fast performance. In a server environment, it's also ideal to store [Docker containers](/serveex/core/docker) and databases on the SSD. More broadly, any data that needs to be accessed frequently and quickly, such as websites, applications, or processing workloads, should be stored on an SSD.
|
||||||
|
|
||||||
|
## Network Card
|
||||||
|
|
||||||
|
A __network card__ allows your machine to communicate with your network (including the internet). It consists of a controller chip and one or more network ports. These ports, often Ethernet ports, can come in different physical formats and support various data transfer standards:
|
||||||
|
|
||||||
|
- __RJ45 Gigabit Ethernet (10/100/1000):__ The standard RJ45 connector, supporting speeds from 10 Mbps (1.25 MB/s) up to 1000 Mbps (125 MB/s).
|
||||||
|
- __RJ45 2.5G:__ Same connector type, supporting up to 2.5 Gbps (2,500 Mbps or 312.5 MB/s).
|
||||||
|
- __RJ45 5G:__ Same connector, supporting up to 5 Gbps (625 MB/s).
|
||||||
|
- __RJ45 10G Base-T:__ Same RJ45 format, supporting up to 10 Gbps (1.25 GB/s).
|
||||||
|
- __SFP 1G:__ SFP port, commonly used for fiber optic connections, supporting speeds up to 1 Gbps.
|
||||||
|
- __SFP+ 10G:__ An enhanced version of the SFP port, also used for fiber optics, supporting up to 10 Gbps.
|
||||||
|
|
||||||
|
::warning
|
||||||
|
|
||||||
|
__Caution:__ Match network gear (router, switch, cables) to your desired speed. For most uses, CAT5E cables are enough; use CAT6A beyond 10 Gbps. Fiber requires additional care (simplex, duplex, transceivers...).
|
||||||
|
::
|
||||||
|
|
||||||
|
The network card is usually built directly into the motherboard, but you can also use dedicated network cards, for example via USB or a PCIe expansion slot.
|
||||||
|
|
||||||
|
In general, for a server setup, it's recommended to have at least two Ethernet ports to ensure redundancy in case one connection fails.
|
||||||
|
|
||||||
|
## Input/Output Ports
|
||||||
|
|
||||||
|
__I/O__ ports allow communication with external devices (displays, keyboard, mouse, network...). Motherboards typically offer:
|
||||||
|
|
||||||
|
- Ethernet ports
|
||||||
|
- USB ports (varied types/speeds)
|
||||||
|
- Video ports
|
||||||
|
- Audio jacks
|
||||||
|
|
||||||
|
Choose a motherboard and expansions based on your I/O needs.
|
||||||
|
|
||||||
|
## Power Supply
|
||||||
|
|
||||||
|
The __power supply unit__ (PSU) is the component that provides electrical power to your machine’s components. It connects to the wall via a power cord and has several output cables that plug into the motherboard and various peripherals, such as hard drives or dedicated graphics cards.
|
||||||
|
|
||||||
|
A power supply is defined by several key characteristics:
|
||||||
|
|
||||||
|
- Wattage (its total power output),
|
||||||
|
- Modularity (whether the cables are fixed or detachable),
|
||||||
|
- Efficiency (measured as a percentage). For example, a 500W PSU with 80% efficiency will actually draw 625W from the wall to deliver 500W to the system.
|
||||||
|
|
||||||
|
Another important factor is the form factor. There are several standard sizes, from ATX L (for larger cases) to SFX (for compact builds). There are also specialized models for rack-mounted servers, which are typically flat and space-efficient.
|
||||||
|
|
||||||
|
To choose the right PSU, a common rule of thumb is to estimate your system’s power needs based on usage, and then double that value. This is because most power supplies operate at optimal efficiency around 50% of their maximum load.
|
||||||
|
|
||||||
|
## Case
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center;">
|
||||||
|
<img src="/img/global/case.svg" alt="Computer case icon" style="max-width: 25%; max-height:230px; margin-right: 20px;">
|
||||||
|
<p>The <strong>case</strong> is also an essential component of your machine. It plays a key role in cooling, through its fans and airflow design, and it determines the form factor compatibility for your motherboard, power supply, and any dedicated GPU you may install.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
Additionally, the case dictates how many HDDs you can install and what formats they support. Some cases are rack-mountable, meaning they can be installed in server racks (server cabinets).
|
||||||
|
|
||||||
|
Choose your case carefully based on your specific needs and the hardware you plan to use.
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
---
|
||||||
|
title: Network
|
||||||
|
description: Overview of networking hardware for homelabs. Switches, NICs, cables, and how to connect your servers efficiently.
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px zIndex=60}
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
A computer network cannot exist without the hardware required to build it. Hardware determines the size of the network, communication speeds, and its overall performance. In this article, we will focus on the simplest types of networks, typically found in home environments.
|
||||||
|
|
||||||
|
## The Router
|
||||||
|
The __router__ is the central hub of your network. It directs __packets__ (the blocks of data that travel across your network) from the sender to the appropriate recipient. It manages the routing of data both within your local network and to/from external networks. In short, it enables devices to communicate with each other and with the internet.
|
||||||
|
|
||||||
|
Everyone has a router at home: it's the __internet box__ provided by your ISP (Internet Service Provider).
|
||||||
|
|
||||||
|
In general, a router consists of:
|
||||||
|
|
||||||
|
- a WAN (Wide Area Network) port that receives data from the internet (or from a higher-level network). For example, it could be a port for a fiber optic connection from your ISP, or an SFP+/RJ45 port for a third-party router.
|
||||||
|
- a switch, i.e., a hub with several __LAN__ (Local Area Network) ports allowing multiple devices to connect to your network. These ports can be RJ45 or SFP/SFP+.
|
||||||
|
- sometimes a built-in WiFi transmitter/receiver.
|
||||||
|
|
||||||
|
A router may also include _firewall_ capabilities, allowing you to restrict traffic from specific devices, as well as _[NAT (Network Address Translation)](/general/networking/nat)_ for port forwarding. It generally includes a _[DHCP (Dynamic Host Configuration Protocol)](/general/networking/nat#dhcp)_ server to automatically assign _IP addresses_ to devices connected to the network.
|
||||||
|
|
||||||
|
The router directly affects communication speeds between devices. The WAN port limits the maximum internet speed you can receive from your ISP. For example, if your subscription offers 5 Gb/s, you’ll need a WAN port that supports at least 5 Gb/s. Likewise, internal device-to-device communication is limited by the speed of the switch. If your devices communicate at 5 Gb/s, the router’s switch must have 5 Gb/s ports. If you're using WiFi 7 equipment and want to enjoy its full speed, your router must support it as well. If you’re using a separate WiFi access point, make sure its network port matches or exceeds the speed of the WiFi it broadcasts, and that the router supports it too.
|
||||||
|
|
||||||
|
Internet speed, number of devices, WiFi speed, and internal network speed: these are the four key factors to consider when choosing an internet box or buying your own router.
|
||||||
|
|
||||||
|
::tip{icon=""}
|
||||||
|
✨ __Tip:__
|
||||||
|
You can easily use a third-party router to manage your network if your ISP’s internet box supports _bridge mode_. In France, only the provider Free offers this option. It is technically possible with other providers that do not support bridge mode, but it can be quite difficult and may prevent you from using all the features a third-party router provides.
|
||||||
|
::
|
||||||
|
|
||||||
|
## The Switch
|
||||||
|
|
||||||
|
The __switch__, or network switch, is a device that allows multiple devices to connect to the network. It acts as a literal hub, connecting directly to the router or to another switch upstream. It helps avoid overloading the switch ports on your router or relocating devices to another room without running a cable from each one back to the router. Another common use case is to segment multiple networks that are managed by the same router.
|
||||||
|
|
||||||
|
There are generally two types of switches:
|
||||||
|
|
||||||
|
- **Unmanaged switches**, the most common. These are plug-and-play: you just plug them in and everything works automatically.
|
||||||
|
- **Managed switches**. These offer a configuration interface (via command line or web UI), allowing you to fine-tune routing rules under the control of the router. They are powerful for creating virtual networks between your devices, but usually require more setup time and are less convenient than simple unmanaged switches.
|
||||||
|
|
||||||
|
::warning
|
||||||
|
|
||||||
|
__Warning:__ Make sure to use a switch with ports that match the speeds supported by your network devices.
|
||||||
|
::
|
||||||
|
|
||||||
|
## Cables
|
||||||
|
|
||||||
|
Cables are essential components of your network. Depending on their type and category, they can limit the bandwidth between devices, so they must be chosen to match your network's specifications. They also need to be compatible with your devices' ports.
|
||||||
|
|
||||||
|
Here’s a quick reference of the most common cable and port standards:
|
||||||
|
|
||||||
|
- **RJ45 Gigabit Ethernet 10/100/1000**: The standard RJ45 connector, supporting speeds from 10 Mbps (1.25 MB/s) to 1000 Mbps (125 MB/s)
|
||||||
|
- **RJ45 2.5G**: Same connector, supporting speeds up to 2.5 Gbps (312.5 MB/s)
|
||||||
|
- **RJ45 5G**: Same connector, supporting speeds up to 5 Gbps (625 MB/s)
|
||||||
|
- **RJ45 10GBase-T**: Same connector, supporting speeds up to 10 Gbps (1.25 GB/s)
|
||||||
|
- **SFP 1G**: SFP port, typically used for fiber optics, supporting up to 1 Gbps
|
||||||
|
- **SFP+ 10G**: Enhanced SFP port, also for fiber, supporting up to 10 Gbps
|
||||||
|
|
||||||
|
### Ethernet Cables
|
||||||
|
|
||||||
|
These copper cables usually use the standard `RJ45` connector. It's the most common network connector found on routers and switches.
|
||||||
|
|
||||||
|
Ethernet cables are divided into categories that define their maximum speed based on distance:
|
||||||
|
|
||||||
|
| Speed | Cable Type | Max Distance |
|
||||||
|
|-----------|------------|--------------|
|
||||||
|
| 10 Gb/s | CAT 6A | 100 m |
|
||||||
|
| | CAT 6 | 55 m |
|
||||||
|
| | CAT 5e | 30 m |
|
||||||
|
| 5 Gb/s | CAT 6 | 100 m |
|
||||||
|
| | CAT 5e | 30 m |
|
||||||
|
| 2.5 Gb/s | CAT 5e | 100 m |
|
||||||
|
| 1 Gb/s | CAT 5e | 100 m |
|
||||||
|
| 100 Mb/s | CAT 5 | 100 m |
|
||||||
|
|
||||||
|
Some of these cables are flat, round, shielded (requiring grounding), etc. Choose based on your setup. What matters is that, for example, if you want to connect a device with a 2.5 Gb/s RJ45 port to a 2.5 Gb/s router, you’ll need at least a `CAT 5e` cable.
|
||||||
|
|
||||||
|
On the other hand, if your device is limited to 100 Mb/s, a simple `CAT 5` cable will suffice.
|
||||||
|
|
||||||
|
Nowadays, in new buildings, it is standard practice to install `CAT 6A` cables inside walls. This way, wall ports are ready to support 10 Gb/s over 100 meters.
|
||||||
|
|
||||||
|
|
||||||
|
### Optical Cables
|
||||||
|
|
||||||
|
Very thin but fragile, optical cables are increasingly appearing in home networks. It often starts with the fiber cable connecting your ISP’s outlet to your box/router. They have several advantages:
|
||||||
|
|
||||||
|
- Extremely compact
|
||||||
|
- Zero electrical consumption (unlike copper, which loses energy as heat)
|
||||||
|
- No electromagnetic radiation (no shielding needed, no signal interference)
|
||||||
|
- Very high speeds over long distances
|
||||||
|
|
||||||
|
For local networking, it's important to understand that several types of fiber cables exist. Their performance depends on both distance and compatibility with the appropriate `transceiver`. Fiber cables connect to your devices' SFP+ ports via a small device called a transceiver, which converts electrical signals to light (and vice versa).
|
||||||
|
|
||||||
|
For local networks, the recommended standard is a **multimode OM3 fiber with LC connectors**, paired with a **10G LC SFP+ transceiver**. This setup allows 10 Gb/s connections and is compatible with most devices featuring SFP+ ports.
|
||||||
|
|
||||||
|
::warning
|
||||||
|
|
||||||
|
__Warning:__ Make sure to use transceivers that are compatible with your devices (routers, switches, or other hardware). There is no universal standard yet, and manufacturers usually specify which brands are supported.
|
||||||
|
::
|
||||||
|
|
||||||
|
|
||||||
|
### DAC Cables
|
||||||
|
|
||||||
|
These are copper cables with integrated `transceivers`. They allow two SFP/SFP+ ports to communicate over short distances without using fragile fiber or RJ45 adapters. However, they consume more energy due to natural copper loss, which is non-negligible.
|
||||||
|
|
||||||
|
|
||||||
|
### SFP+ Transceivers
|
||||||
|
|
||||||
|
These let you connect different types of cables to your SFP/SFP+ ports. Variants are available for:
|
||||||
|
|
||||||
|
- Fiber optic
|
||||||
|
- DAC
|
||||||
|
- RJ45
|
||||||
|
|
||||||
|
::warning
|
||||||
|
|
||||||
|
__Warning:__ RJ45 transceivers consume a lot of energy due to copper signal loss and can generate significant heat. Low-power models (under 2W) exist and are generally rated for longer cables (e.g., 80m instead of 30m). Surprisingly, these are preferred over short-distance models because they generate less heat and consume less energy, making them more compatible with sensitive devices. Using the wrong type can cause network degradation or even outages.
|
||||||
|
::
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
title: The ProloNAS
|
||||||
|
description: Build a capable home server on a budget using an Intel N100 mini PC. A practical guide to getting started with self-hosting for under $130.
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px zIndex=60}
|
||||||
|
|
||||||
|
::note
|
||||||
|
This article was written before the __massive__ increase in computer hardware prices we've been experiencing since late 2025. However, setting costs aside, it remains just as relevant.
|
||||||
|
::
|
||||||
|
|
||||||
|
When you decide to dive into the adventure of running your own home server, the same questions usually come up: _“Where should I start?”_, _“Isn’t it expensive?”_. And often, people either give up or end up buying a proprietary NAS that they’ll throw away a year later once they realize it only brings headaches and wasted money.
|
||||||
|
|
||||||
|
A server isn’t a piece of furniture. It’s simply any computer capable of running Linux. That’s why mini PCs powered by **Intel N100** processors are so popular: for around $100–130 on Chinese platforms, you can get a machine that runs **24/7** for years, capable of handling everything you’d expect from a home server or personal cloud without sacrificing performance.
|
||||||
|
|
||||||
|
It’s **objectively inexpensive**, and anyone with a bit of curiosity can get started.
|
||||||
|
|
||||||
|
A mini PC for $100 + a USB dock for $50 that holds multiple hard drives = a complete platform for $150, versus **$350–1200** for branded NAS systems.
|
||||||
|
|
||||||
|
That’s all a **ProloNAS** is: a NAS anyone can afford. The name is a French pun, *prolo* (slang for "working-class") plus *NAS*, a home server for the rest of us, not just people who can drop $1000 on a branded box. It’s then up to you to scale your storage capacity according to your needs.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Example Hardware
|
||||||
|
|
||||||
|
- Mini PC (**Note: choose 16 GB / 512 GB**): [Aliexpress](https://fr.aliexpress.com/item/1005008477986765.html)
|
||||||
|
- DAS (Direct Attached Storage) (**Note: select “EU plug”**): [Aliexpress](https://fr.aliexpress.com/item/1005007933987260.html)
|
||||||
|
- More refined alternative with a fan: [Amazon](https://www.amazon.fr/Boîtier-Disque-Ventilateur-Supportant-Capacité/dp/B0DD3GSSCX)
|
||||||
|
|
||||||
|
> *These are not affiliate links, buy wherever you prefer.*
|
||||||
|
|
||||||
|
## Why a NAS?
|
||||||
|
|
||||||
|
A **NAS** (Network Attached Storage) is a machine centered around storage, designed to be shared over a network. The idea is to have a **reliable and secure** storage space that serves as the backbone for your personal services and apps such as a self-hosted cloud like [Nextcloud](/serveex/cloud/nextcloud), a photo sync tool like [Immich](/serveex/cloud/immich), or a media server like [Jellyfin](/serveex/media/jellyfin). You can also store camera footage, backups, or even development projects on it.
|
||||||
|
|
||||||
|
### But why not just use a mini PC with an external hard drive?
|
||||||
|
|
||||||
|
Sure, a simple mini PC with 1–2 TB of storage will do for most people. And your movie collection might fit on an external drive of a few extra terabytes. But that’s **neither reliable nor scalable**: a single shock or hardware failure could permanently destroy your data.
|
||||||
|
|
||||||
|
A real NAS is built around **storage reliability**. It uses redundancy strategies like [RAID](/general/storage/raid) to protect against drive failure, and snapshot systems like [ZFS](/general/storage/zfs) to guard against corruption.
|
||||||
|
|
||||||
|
In short, a NAS lets you **host everything yourself** that you currently entrust to third parties while maintaining control, reliability, and data safety.
|
||||||
|
|
||||||
|
## The Problem with Consumer NAS Systems
|
||||||
|
|
||||||
|
Many brands offer “ready-to-use” NAS platforms: Synology, QNAP, Ugreen, and others. They promise simplicity and sleek web interfaces, but the reality is quite different.
|
||||||
|
|
||||||
|
### First, the price.
|
||||||
|
|
||||||
|
$350 is the starting price for a 2-bay NAS (without drives) from Synology. For that, you get a 2019 processor, no SSD slot for the OS, and a measly 2 GB of RAM.
|
||||||
|
|
||||||
|
Now, compare that to the **ProloNAS**: an N100 (4 cores), 16 GB RAM, a 512 GB SSD for $100–130, plus a 4-bay DAS for $55. That’s **half the price** of a 2-bay Synology, and **a quarter of the price** of a 4-bay one.
|
||||||
|
|
||||||
|
### Locked-Down Operating Systems
|
||||||
|
|
||||||
|
“Yeah, but at least with a Synology, you plug it in and everything just works.”
|
||||||
|
|
||||||
|
One year.
|
||||||
|
That’s how long it took before I threw away my Synology and realized I should have started with a **ProloNAS** (which later became a full-fledged server).
|
||||||
|
|
||||||
|
Manufacturers ship heavily customized Linux-based OSes: ancient kernels, limited app repositories, and complete dependence on their proprietary tools. As a result, you can’t fully tailor your NAS to your needs, and many Docker containers simply won’t run because the kernel is too old.
|
||||||
|
|
||||||
|
### Total Vendor Lock-In
|
||||||
|
|
||||||
|
“I’m fine with the built-in apps.”
|
||||||
|
Yeah, I thought so too… until my needs exploded: media center, password manager, Git hosting, strong authentication, web hosting, and more.
|
||||||
|
|
||||||
|
Why stay stuck with half-baked proprietary tools when you can rely on **open-source projects** that are regularly updated and interoperable?
|
||||||
|
|
||||||
|
And what happens when the manufacturer decides to drop support or limit hardware compatibility? It’s already happened, Synology made certain drives **incompatible** unless they were “certified” by them. They even **disabled hardware transcoding** on their NAS units: [see here](https://www.cachem.fr/synology-desactive-transcodage-materiel-nas/).
|
||||||
|
|
||||||
|
In short, you have **no control** over a product that isn’t open, nor truly yours.
|
||||||
|
|
||||||
|
## OK, but how do I turn my Mini PC Serveex into a ProloNAS?
|
||||||
|
|
||||||
|
As mentioned earlier: by adding a **DAS (drive hub)** and setting up a redundant storage system with [RAID](/general/storage/raid) and [ZFS](/general/storage/zfs), you can transform your mini PC into a robust and scalable NAS.
|
||||||
|
|
||||||
|
Enjoy !
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
title: Linux tips for dummies
|
||||||
|
icon: i-lucide-terminal
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
---
|
||||||
|
title: Command line basics
|
||||||
|
description: Understand how a Linux command is built, learn the essential terminal commands, what their names mean, and get a cheat sheet to keep at hand.
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px zIndex=60}
|
||||||
|
|
||||||
|
A server has no desktop, no icons and no mouse. Everything happens in a terminal, and that black window with a blinking cursor is the single thing that puts people off self-hosting. It shouldn't: the terminal is just a conversation. You type one line, the machine does exactly that and answers. Nothing more magic than a search bar, except it does far more and never hides an option behind three menus.
|
||||||
|
|
||||||
|
The good news is that you don't need to know a hundred commands. Ten of them cover almost everything you'll do on a home server, and they all follow the same pattern. Learn the pattern first, and every command you meet later becomes readable, even the ones you've never seen.
|
||||||
|
|
||||||
|
## How a command is built
|
||||||
|
|
||||||
|
Every command line, without exception, is the same sentence: **what to run**, **how to run it**, **what to run it on**.
|
||||||
|
|
||||||
|
```text [Anatomy of a command]
|
||||||
|
sudo apt install -y nano
|
||||||
|
│ │ │ │ └─ argument: what the command works on
|
||||||
|
│ │ │ └──── option: changes how it behaves
|
||||||
|
│ │ └──────────── subcommand: what the program should do
|
||||||
|
│ └──────────────── the program you're running
|
||||||
|
└───────────────────── run it with administrator rights
|
||||||
|
```
|
||||||
|
|
||||||
|
Read out loud, that line says "as an administrator, ask the package manager to install the nano package, and don't ask me to confirm". Spaces are what separate the pieces, which is why a folder named `My Backups` has to be quoted (`cd "My Backups"`) or the shell reads it as two different things.
|
||||||
|
|
||||||
|
### Options, short and long
|
||||||
|
|
||||||
|
Options change how a command behaves. They come in two flavours, and most commands accept both:
|
||||||
|
|
||||||
|
- **Short**, a single dash and a single letter: `ls -a`. They can be stacked, so `ls -l -a -h` is usually written `ls -lah`.
|
||||||
|
- **Long**, two dashes and a whole word: `ls --all`. Longer to type, but you can still tell what it does six months later, which is why they're the better choice in a script.
|
||||||
|
|
||||||
|
Some options expect a value right after them: `ssh-keygen -t ed25519` (`-t` for type), `rsync --exclude @eaDir`. And case matters, always. In `ls`, `-r` reverses the sort order while `-R` walks into subfolders. Same letter, different case.
|
||||||
|
|
||||||
|
### Arguments and paths
|
||||||
|
|
||||||
|
The argument is the target: a file, a folder, a package name, an address. Many commands accept several at once, separated by spaces, which is what makes the terminal fast: `rm file1.txt file2.txt file3.txt` deletes three files in one go.
|
||||||
|
|
||||||
|
When the target is a place on the disk, you write it as a path, and there are a few shortcuts worth knowing:
|
||||||
|
|
||||||
|
| Path | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| `/` | the root of the whole system, everything lives under it |
|
||||||
|
| `~` | your own home folder, `/home/username` |
|
||||||
|
| `.` | the folder you're currently in |
|
||||||
|
| `..` | the folder just above |
|
||||||
|
| `/var/log` | an **absolute** path, same result from anywhere |
|
||||||
|
| `logs/today` | a **relative** path, understood from where you currently stand |
|
||||||
|
|
||||||
|
::note{to="/general/linux/filesystem"}
|
||||||
|
Which folder holds what is a subject of its own, covered in **folders and partitions**.
|
||||||
|
::
|
||||||
|
|
||||||
|
The prompt itself tells you where you are: in `username@serveex:~/docker$`, you're logged in as `username` on the machine named `serveex`, inside the `docker` folder of your home. That final `$` means a normal user. If it ever shows `#`, you're root and every typo counts double.
|
||||||
|
|
||||||
|
### Getting help
|
||||||
|
|
||||||
|
Two habits make you independent from tutorials. `command --help` prints a quick summary of every option, and `man command` opens the full manual (`man` for *manual*), which you leave by pressing :kbd{value="Q"}.
|
||||||
|
|
||||||
|
::tip{icon=""}
|
||||||
|
✨ __Tip:__ three keyboard habits that change everything: :kbd{value="Tab"} completes the file or folder name you started typing, so you almost never type a full path; the :kbd{value="Up"} arrow brings back your previous commands, which saves retyping a long line for one character; and :kbd{value="Ctrl"} + :kbd{value="C"} stops whatever is currently running.
|
||||||
|
::
|
||||||
|
|
||||||
|
### Chaining commands
|
||||||
|
|
||||||
|
Once the pattern clicks, commands can be plugged into each other:
|
||||||
|
|
||||||
|
- `&&` runs the next one only if the previous one succeeded: `sudo apt update && sudo apt full-upgrade`
|
||||||
|
- `|`, the pipe, feeds the output of one command into another: `ls -l | grep backup` lists the folder, then keeps only the lines containing "backup"
|
||||||
|
- `>` writes the output into a file instead of the screen, and `>>` adds to the end of that file: `df -h > disk-report.txt`
|
||||||
|
|
||||||
|
## The commands you'll actually use
|
||||||
|
|
||||||
|
Most command names are abbreviations of an English phrase. Once you know what they stand for, they stop looking like keyboard noise.
|
||||||
|
|
||||||
|
### `pwd`, print working directory
|
||||||
|
|
||||||
|
Tells you where you are. It changes nothing, it just answers the question.
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
pwd
|
||||||
|
```
|
||||||
|
|
||||||
|
```console [Output]
|
||||||
|
/home/username/docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ls`, list
|
||||||
|
|
||||||
|
Lists what's in the current folder. On its own it prints bare names, so it's almost always used with options: `-l` for the long format with sizes, dates and permissions, `-a` to also show hidden files (the ones starting with a dot), `-h` for sizes in K/M/G instead of raw bytes.
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
ls -lah
|
||||||
|
```
|
||||||
|
|
||||||
|
```console [Output]
|
||||||
|
total 20K
|
||||||
|
drwxr-xr-x 4 username username 4.0K Sep 5 10:12 .
|
||||||
|
drwxr-xr-x 18 username username 4.0K Sep 4 21:03 ..
|
||||||
|
-rw-r--r-- 1 username username 512 Sep 5 10:12 .env
|
||||||
|
-rw-r--r-- 1 username username 1.2K Sep 5 09:58 compose.yaml
|
||||||
|
drwxr-xr-x 3 username username 4.0K Sep 2 18:44 immich
|
||||||
|
```
|
||||||
|
|
||||||
|
The first column is the permissions, `d` at the very start meaning it's a folder. Then the owner, the size, the date of the last change, and the name.
|
||||||
|
|
||||||
|
### `cd`, change directory
|
||||||
|
|
||||||
|
Moves you around. With a path it goes there, with `..` it goes up one level, and with nothing at all it takes you back home.
|
||||||
|
|
||||||
|
```console [Terminal]
|
||||||
|
username@serveex:~/docker$ cd /var/log
|
||||||
|
username@serveex:/var/log$ cd ..
|
||||||
|
username@serveex:/$ cd
|
||||||
|
username@serveex:~$
|
||||||
|
```
|
||||||
|
|
||||||
|
Notice the prompt following you around: it always shows where you currently stand, so you rarely need `pwd` in practice.
|
||||||
|
|
||||||
|
### `mkdir`, make directory
|
||||||
|
|
||||||
|
Creates a folder. Several at once if you list them, and `-p` creates the whole chain of parents in one shot, which is the version you'll actually use.
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
mkdir backups
|
||||||
|
mkdir -p docker/immich/config
|
||||||
|
```
|
||||||
|
|
||||||
|
```console [Output]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing. That's not a bug, it's the rule: most commands say nothing when they succeed and only speak up when something goes wrong. Silence is good news, and `ls` confirms the folder is there.
|
||||||
|
|
||||||
|
### `cp` and `mv`, copy and move
|
||||||
|
|
||||||
|
`cp` copies, `mv` moves. Same shape both times: first the source, then the destination. Copying a folder needs `-r`, for *recursive*, since a folder means everything inside it too. `mv` doubles as the rename command, because renaming a file is just moving it to a new name.
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
cp compose.yaml compose.yaml.bak
|
||||||
|
cp -r config/ config-backup/
|
||||||
|
mv old-name.txt new-name.txt
|
||||||
|
ls
|
||||||
|
```
|
||||||
|
|
||||||
|
```console [Output]
|
||||||
|
compose.yaml compose.yaml.bak config config-backup new-name.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Three silent commands, and `ls` showing the result: the copy sits next to the original, the folder was duplicated, and `old-name.txt` is gone because moving it to another name is exactly what renaming means.
|
||||||
|
|
||||||
|
### `rm`, remove
|
||||||
|
|
||||||
|
Deletes. There is no recycle bin, no undo, no confirmation dialog. `-r` deletes a folder and its contents, `-f` forces without asking.
|
||||||
|
|
||||||
|
::warning{to="/nonsense/bash/rm-confirmation"}
|
||||||
|
`rm -rf` is the command that wipes homelabs. It doesn't check, doesn't warn, and doesn't stop. Read the path twice before pressing :kbd{value="Enter"}, especially when the line starts with `sudo` and contains a `/` or a `*`. You can also prevent this by wrapping `sudo` in a small Bash function that asks **"are you sure?"** before it lets an `rm` through, covered in **rm confirmation guard**.
|
||||||
|
::
|
||||||
|
|
||||||
|
### `cat` and `nano`, read and edit
|
||||||
|
|
||||||
|
`cat` (short for *concatenate*) dumps a whole file to the screen, perfect for a short config. For anything longer, `less` scrolls through it (named as a joke on `more`, the older pager it replaced), and you quit it with :kbd{value="Q"}.
|
||||||
|
|
||||||
|
To actually change a file, `nano` opens a simple editor: arrows to move, :kbd{value="Ctrl"} + :kbd{value="O"} to save, :kbd{value="Ctrl"} + :kbd{value="X"} to leave.
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
cat .env
|
||||||
|
```
|
||||||
|
|
||||||
|
```properties [Output]
|
||||||
|
PUID=1000
|
||||||
|
PGID=1000
|
||||||
|
TZ=Europe/Paris
|
||||||
|
```
|
||||||
|
|
||||||
|
### `grep`, search inside files
|
||||||
|
|
||||||
|
`grep` stands for *global regular expression print*, which is a mouthful for "find me this text". You give it what to look for and where, and it prints every matching line. `-r` searches a whole folder, `-i` ignores upper and lower case, `-n` shows line numbers.
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
grep -rin "password" /home/username/docker
|
||||||
|
```
|
||||||
|
|
||||||
|
```console [Output]
|
||||||
|
/home/username/docker/immich/.env:6:DB_PASSWORD=changeme
|
||||||
|
/home/username/docker/vaultwarden/compose.yaml:14: ADMIN_PASSWORD=hunter2
|
||||||
|
```
|
||||||
|
|
||||||
|
Each line is the file, then the line number inside it, then the matching line itself. Very handy for the day you can't remember which stack holds a setting.
|
||||||
|
|
||||||
|
### `sudo`, run as administrator
|
||||||
|
|
||||||
|
*Substitute user do*. A normal user can't touch the system's files, which is exactly what protects you from wrecking the machine by accident. Prefixing a command with `sudo` runs that single command with administrator rights, and asks for your password the first time.
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
nano /etc/ssh/sshd_config
|
||||||
|
```
|
||||||
|
|
||||||
|
```console [Output]
|
||||||
|
Error writing /etc/ssh/sshd_config: Permission denied
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo nano /etc/ssh/sshd_config
|
||||||
|
```
|
||||||
|
|
||||||
|
```console [Output]
|
||||||
|
[sudo] password for username:
|
||||||
|
```
|
||||||
|
|
||||||
|
::note
|
||||||
|
If a command answers `Permission denied`, that's usually the whole problem: it needed `sudo`. Resist the reflex of putting `sudo` on everything though, a file created as root will keep annoying you afterwards because your normal user no longer owns it.
|
||||||
|
::
|
||||||
|
|
||||||
|
## Cheat sheet
|
||||||
|
|
||||||
|
The ones worth keeping at hand, and where their names come from.
|
||||||
|
|
||||||
|
| Command | Short for | What it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `pwd` | print working directory | Shows where you are |
|
||||||
|
| `ls` | list | Lists files and folders |
|
||||||
|
| `cd` | change directory | Moves you somewhere else |
|
||||||
|
| `mkdir` | make directory | Creates a folder |
|
||||||
|
| `touch` | plain English | Creates an empty file, or refreshes its date |
|
||||||
|
| `cp` | copy | Copies a file or folder |
|
||||||
|
| `mv` | move | Moves or renames |
|
||||||
|
| `rm` | remove | Deletes, permanently |
|
||||||
|
| `cat` | concatenate | Prints a file to the screen |
|
||||||
|
| `less` | a pun on `more` | Scrolls through a long file |
|
||||||
|
| `nano` | the editor replacing Pico | Edits a file |
|
||||||
|
| `grep` | global regular expression print | Searches for text |
|
||||||
|
| `find` | plain English | Searches for files by name, size or date |
|
||||||
|
| `man` | manual | Opens a command's full documentation |
|
||||||
|
| `df` | disk free | Shows free space per partition |
|
||||||
|
| `lsblk` | list block devices | Draws the tree of disks and partitions |
|
||||||
|
| `du` | disk usage | Shows what a folder weighs |
|
||||||
|
| `ps` | process status | Lists running processes |
|
||||||
|
| `htop` | Hisham's `top` | Live view of CPU, RAM and processes |
|
||||||
|
| `kill` | plain English | Stops a process by its number |
|
||||||
|
| `chmod` | change mode | Changes a file's permissions |
|
||||||
|
| `chown` | change owner | Changes who owns a file |
|
||||||
|
| `sudo` | substitute user do | Runs one command as administrator |
|
||||||
|
| `apt` | Advanced Package Tool | Installs, updates and removes packages |
|
||||||
|
| `systemctl` | control systemd | Starts, stops and enables services |
|
||||||
|
| `ssh` | secure shell | Opens a session on a remote machine |
|
||||||
|
| `scp` | secure copy | Copies files over SSH |
|
||||||
|
| `tar` | tape archive | Packs and unpacks archives |
|
||||||
|
| `wget` | web get | Downloads a file from a URL |
|
||||||
|
| `curl` | client URL | Sends a request to a URL |
|
||||||
|
| `history` | plain English | Lists the commands you typed before |
|
||||||
|
|
||||||
|
::tip{icon=""}
|
||||||
|
✨ __Tip:__ nobody memorises this. You'll look up the same three options for weeks, then one day realise you're typing them without thinking. Until then, `--help` and this table are perfectly legitimate.
|
||||||
|
::
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
title: Folders and partitions
|
||||||
|
description: How the Debian filesystem is organised, what each top-level folder holds, how partitions differ from folders, and the habits that keep a server tidy.
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px zIndex=60}
|
||||||
|
|
||||||
|
Windows gives every disk its own letter. Linux doesn't: there is exactly one tree, it starts at `/`, and everything else hangs off it, including your other disks. A second drive isn't `D:`, it's *mounted* at a folder of the tree, `/mnt/data` for example, and from that point on it looks like any other folder. Odd at first, very practical afterwards, since a program never has to care which physical disk it's writing to.
|
||||||
|
|
||||||
|
## The tree
|
||||||
|
|
||||||
|
That tree isn't arbitrary either. Every Debian install has the same folders in the same places, which is why a tutorial written for someone else's server applies to yours.
|
||||||
|
|
||||||
|
| Folder | What's in it |
|
||||||
|
| --- | --- |
|
||||||
|
| `/home` | Users' files. Yours is `/home/username`, also written `~` |
|
||||||
|
| `/root` | The root account's own home, not to be confused with `/` |
|
||||||
|
| `/etc` | System configuration, all of it plain text files |
|
||||||
|
| `/var` | Data that grows: logs in `/var/log`, Docker in `/var/lib/docker` |
|
||||||
|
| `/tmp` | Temporary files, emptied at every reboot |
|
||||||
|
| `/usr` | The installed programs themselves, managed by `apt` |
|
||||||
|
| `/opt` | Software installed outside the package manager |
|
||||||
|
| `/mnt` and `/media` | Where extra disks get mounted, `/media` for removable ones |
|
||||||
|
| `/boot` | The kernel and the bootloader. On a default UEFI Debian install it lives on `/`; only `/boot/efi` (the EFI system partition) is separate |
|
||||||
|
| `/dev` | Your hardware, exposed as files (`/dev/sda` is a disk) |
|
||||||
|
| `/proc` and `/sys` | The kernel's live state, invented on the fly, not real files |
|
||||||
|
|
||||||
|
## Folders are not partitions
|
||||||
|
|
||||||
|
Partitions are a different question from folders. A minimal Debian install typically creates two, one for `/` and one for swap, so every folder above except `/boot/efi` lives on the same partition and shares the same free space. Two commands to see the reality of it: `lsblk` draws the tree of disks and partitions, `df -h` shows how full each one is.
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
lsblk
|
||||||
|
```
|
||||||
|
|
||||||
|
```console [Output]
|
||||||
|
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
|
||||||
|
sda 8:0 0 465.8G 0 disk
|
||||||
|
├─sda1 8:1 0 512M 0 part /boot/efi
|
||||||
|
├─sda2 8:2 0 461.3G 0 part /
|
||||||
|
└─sda3 8:3 0 4G 0 part [SWAP]
|
||||||
|
sdb 8:16 0 3.6T 0 disk
|
||||||
|
└─sdb1 8:17 0 3.6T 0 part /mnt/data
|
||||||
|
```
|
||||||
|
|
||||||
|
## A few habits worth taking
|
||||||
|
|
||||||
|
- **Give your Docker stacks one home, and keep them there.** `/srv` is the folder the standard reserves for data served by the machine, which makes it the tidiest choice for compose files and their bind mounts. [Serveex](/serveex/introduction) puts everything in `/srv/docker`, one folder per stack. What matters is picking one place and staying there, rather than scattering half of them into your home folder.
|
||||||
|
- **Your own files go in your home.** Scripts in `~/bin`, notes, downloads, anything personal. `/root` is the root account's home, not a convenient place to drop things.
|
||||||
|
- **Never edit anything under `/usr` or `/bin` by hand.** `apt` owns those, and your changes disappear at the next upgrade. What you're allowed to configure lives in `/etc`.
|
||||||
|
- **In `/etc`, prefer a drop-in file over editing the main one.** Many services read every `.conf` in a `something.d/` folder next to their main config, `/etc/ssh/sshd_config.d/` for instance. Your file then survives a package upgrade that rewrites the original.
|
||||||
|
- **Mount data disks by UUID, not by `/dev/sdb`.** Device letters are assigned in the order the kernel finds the disks, so they can swap after a reboot or a new drive. `lsblk -f` gives you the UUID to put in `/etc/fstab`.
|
||||||
|
- **Keep an eye on `/var`.** Docker images, container logs and system logs all pile up there, on the same partition as the rest. `du -sh /var/lib/docker` tells you what the containers weigh, `df -h` whether you should worry.
|
||||||
|
- **Don't create your files with `sudo` when you don't have to.** A file created as root inside your home stays owned by root, and you'll be fighting permission errors over it for weeks.
|
||||||
|
|
||||||
|
::note{to="/general/linux/cli-basics"}
|
||||||
|
Everything here assumes you can already move around a terminal. If `cd`, `ls` and `sudo` don't mean much yet, start with the **command line basics**.
|
||||||
|
::
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
---
|
||||||
|
title: Handy CLI tools
|
||||||
|
description: A handful of terminal tools worth installing on a home server, what each one replaces, and step-by-step instructions to install and use them.
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px zIndex=60}
|
||||||
|
|
||||||
|
A minimal Debian install ships with the strict minimum, which means the tools you get are the ones from 1995. They work, but reading `df` output or hunting for what filled a disk with `du` is needlessly painful when better versions exist and cost nothing to install.
|
||||||
|
|
||||||
|
Everything below except the last one comes straight from Debian's repositories, so there's no third-party source to trust and `apt` keeps them updated along with the rest of the system.
|
||||||
|
|
||||||
|
::note{to="/general/linux/cli-basics"}
|
||||||
|
Every command here is typed in a terminal over SSH. If `sudo`, `apt` and `cd` don't mean much yet, start with the **command line basics**.
|
||||||
|
::
|
||||||
|
|
||||||
|
## The short version
|
||||||
|
|
||||||
|
| Tool | Replaces | What for |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `btop` | `top`, `htop` | Watching CPU, RAM and processes |
|
||||||
|
| `duf` | `df -h` | Free space, readable |
|
||||||
|
| `ncdu` | `du -sh` | Finding what filled the disk |
|
||||||
|
| `tldr` | `man` | The five commands you actually need |
|
||||||
|
| `lazydocker` | `docker ps` and friends | Managing containers over SSH |
|
||||||
|
| `ufw` | raw `iptables` | A firewall you can actually read |
|
||||||
|
|
||||||
|
## The impatient version
|
||||||
|
|
||||||
|
One line installs all the packaged ones, and each section below explains what you just got.
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install btop duf ncdu tealdeer ufw
|
||||||
|
```
|
||||||
|
|
||||||
|
## `btop`, watching what the machine is doing
|
||||||
|
|
||||||
|
The modern replacement for `top` and `htop`: CPU, RAM, disks, network and processes on one screen, with graphs, colors and a working mouse. This is what you open when something feels slow.
|
||||||
|
|
||||||
|
::steps{level="4"}
|
||||||
|
#### Install it
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo apt install btop
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run it
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo btop
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Click a process to select it, :kbd{value="Esc"} opens the menu, :kbd{value="Q"} quits. The `+` and `-` keys fold and unfold the panels if the screen feels crowded.
|
||||||
|
|
||||||
|
#### Done!
|
||||||
|
::
|
||||||
|
|
||||||
|
## `duf`, disk space that reads like a table
|
||||||
|
|
||||||
|
`df -h` prints every loop device Docker ever created and leaves you squinting at the columns. `duf` shows the same information grouped, aligned and colored, with a usage bar per filesystem.
|
||||||
|
|
||||||
|
::steps{level="4"}
|
||||||
|
#### Install it
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo apt install duf
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run it
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo duf
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Local disks, network shares and system mounts are grouped separately. Add `--only local` to hide the pseudo-filesystems Docker leaves behind.
|
||||||
|
|
||||||
|
#### Done!
|
||||||
|
::
|
||||||
|
|
||||||
|
## `ncdu`, finding what ate the disk
|
||||||
|
|
||||||
|
When `duf` tells you the disk is full, `ncdu` tells you why. It walks a folder, sorts everything by real size, and lets you drill down with the arrow keys instead of running `du -sh *` twenty times.
|
||||||
|
|
||||||
|
::steps{level="4"}
|
||||||
|
#### Install it
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo apt install ncdu
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Point it at a folder
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo ncdu /srv/docker
|
||||||
|
```
|
||||||
|
|
||||||
|
Arrows to move, :kbd{value="Enter"} to open a folder, :kbd{value="D"} to delete the selected item, :kbd{value="Q"} to quit. On a big disk the first scan takes a moment, it's reading everything.
|
||||||
|
|
||||||
|
::warning
|
||||||
|
:kbd{value="D"} deletes immediately, with a single confirmation and no recycle bin. Run `ncdu` without `sudo` when you're only looking, so a mistyped key can't touch anything the system owns.
|
||||||
|
::
|
||||||
|
|
||||||
|
#### Done!
|
||||||
|
::
|
||||||
|
|
||||||
|
## `tldr`, the manual without the 400 lines
|
||||||
|
|
||||||
|
`man tar` is exhaustive and unreadable. `tldr tar` gives you the five commands people actually type, with a one-line explanation each. It's community-maintained examples rather than a substitute for the real manual, and on Debian the client is packaged as `tealdeer`.
|
||||||
|
|
||||||
|
::steps{level="4"}
|
||||||
|
#### Install it
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo apt install tealdeer
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Download the page cache
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
tldr --update
|
||||||
|
```
|
||||||
|
|
||||||
|
The examples are fetched once and stored locally, so the command works offline afterwards. Run it again every few months.
|
||||||
|
|
||||||
|
#### Ask it something
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
tldr rsync
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Done!
|
||||||
|
::
|
||||||
|
|
||||||
|
## `lazydocker`, managing containers from the terminal
|
||||||
|
|
||||||
|
The one exception: it isn't packaged by Debian. It's a full text interface for Docker, containers, images, volumes and logs in one screen, with keys to restart, stop or follow the logs of anything. Handy when you're already in SSH and don't feel like opening Dockge.
|
||||||
|
|
||||||
|
::steps{level="4"}
|
||||||
|
#### Download the latest release
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
LAZYDOCKER_VERSION=$(curl -s https://api.github.com/repos/jesseduffield/lazydocker/releases/latest | grep -Po '"tag_name": "v\K[^"]*')
|
||||||
|
curl -Lo /tmp/lazydocker.tar.gz "https://github.com/jesseduffield/lazydocker/releases/latest/download/lazydocker_${LAZYDOCKER_VERSION}_Linux_x86_64.tar.gz"
|
||||||
|
```
|
||||||
|
|
||||||
|
The release asset's filename embeds the version number, so it can't be fetched with a plain `latest` link; the first command reads the current version from GitHub's API instead of hardcoding one that will go stale. Take `arm64` instead of `x86_64` if the server is a Raspberry Pi or similar.
|
||||||
|
|
||||||
|
#### Install the binary
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo tar -xzf /tmp/lazydocker.tar.gz -C /usr/local/bin lazydocker
|
||||||
|
rm /tmp/lazydocker.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
`/usr/local/bin` is the folder meant for software you install yourself, which is why `apt` never touches it.
|
||||||
|
|
||||||
|
#### Check it landed
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
lazydocker --version
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run it
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo lazydocker
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
It needs access to the Docker socket, hence the `sudo` unless your user is in the `docker` group. The keys worth knowing:
|
||||||
|
|
||||||
|
| Key | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `1` to `6` | Jump to a panel: projects, services, containers, images, volumes, networks |
|
||||||
|
| Arrows | Move inside the panel, the right side follows the selection |
|
||||||
|
| :kbd{value="Enter"} | Focus the main panel on the right, :kbd{value="Esc"} comes back |
|
||||||
|
| `x` | Open the menu of everything you can do with what's selected |
|
||||||
|
| `m` | Follow the logs |
|
||||||
|
| `s` / `r` / `p` | Stop, restart, pause the selected container |
|
||||||
|
| `E` | Open a shell inside the container |
|
||||||
|
| `d` | Remove it |
|
||||||
|
| `b` | Bulk commands, pruning images and volumes among others |
|
||||||
|
| `/` | Filter the list |
|
||||||
|
| `+` and `_` | Grow or shrink the panels |
|
||||||
|
| `q` | Quit |
|
||||||
|
|
||||||
|
Case matters: `E` opens a shell in the container, `e` hides the stopped ones.
|
||||||
|
|
||||||
|
The [full list](https://github.com/jesseduffield/lazydocker/blob/master/docs/keybindings/Keybindings_en.md) is in the project's documentation.
|
||||||
|
|
||||||
|
::note
|
||||||
|
Being outside `apt` also means it won't be updated by `apt full-upgrade`. Repeat these steps when you want a newer version.
|
||||||
|
::
|
||||||
|
|
||||||
|
#### Done!
|
||||||
|
::
|
||||||
|
|
||||||
|
## `ufw`, a firewall you can actually read
|
||||||
|
|
||||||
|
Debian's firewall (`iptables`/`nftables` under the hood) is powerful and unreadable directly. `ufw`, *uncomplicated firewall*, is a thin layer on top that turns it into short, plain-English rules: block everything by default and open only what you actually expose.
|
||||||
|
|
||||||
|
::steps{level="4"}
|
||||||
|
#### Install it
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo apt install ufw
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Set the default policy
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo ufw default deny incoming
|
||||||
|
sudo ufw default allow outgoing
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing gets in unless a rule says so, everything the server itself initiates still goes out normally.
|
||||||
|
|
||||||
|
#### Allow what you actually need
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo ufw allow OpenSSH
|
||||||
|
sudo ufw allow 443/tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
`OpenSSH` is a built-in profile that matches the SSH port, no need to remember which one. Add one `allow` per port you expose, [SWAG](/serveex/core/swag) on `443` for instance.
|
||||||
|
|
||||||
|
::warning
|
||||||
|
Allow SSH **before** enabling the firewall, in the next step. Enable it first and the very connection you're typing in gets cut, with no screen left plugged in to fix it.
|
||||||
|
::
|
||||||
|
|
||||||
|
#### Enable it
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo ufw enable
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Check the rules
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo ufw status verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
```console [Output]
|
||||||
|
Status: active
|
||||||
|
Logging: on (low)
|
||||||
|
Default: deny (incoming), allow (outgoing), disabled (routed)
|
||||||
|
|
||||||
|
To Action From
|
||||||
|
-- ------ ----
|
||||||
|
22/tcp (OpenSSH) ALLOW IN Anywhere
|
||||||
|
443/tcp ALLOW IN Anywhere
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Done!
|
||||||
|
::
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
title: Serveex
|
||||||
|
icon: i-noto-microscope
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
---
|
||||||
|
title: Introduction
|
||||||
|
description: Introduction to Serveex, a personal homelab project to self-host everyday services using Debian and Docker, replacing cloud services as Google, Apple or Netflix.
|
||||||
|
navigation:
|
||||||
|
icon: i-lucide-bookmark
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px zIndex=60}
|
||||||
|
## A Home Lab by a Beginner, for Beginners
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Serveex** is primarily a personal project aimed at hosting as many everyday services as possible at home, without relying on proprietary platforms (Google, Apple, Netflix, etc.). The goal was to experiment, learn, and document every step along the way. This is purely a scientific project and is not intended for production use.
|
||||||
|
|
||||||
|
A big thanks to **Nipah** for sharing his infinite knowledge and, above all, for his patience.
|
||||||
|
|
||||||
|
::note{icon=""}
|
||||||
|
📋 **Prerequisites:**
|
||||||
|
- Have [an online VPS](https://www.it-connect.fr/les-serveurs-prives-virtuels-vps-pour-les-debutants/) or a local machine: ideally a mini PC (you can find N100 models for around €100), but it also works on a laptop or [a virtual machine](https://openclassrooms.com/fr/courses/2035806-virtualisez-votre-architecture-et-vos-environnements-de-travail/6313946-installez-virtualbox). The [Freebox Delta/Ultra offer virtual machines](https://next.ink/3493/machines-virtuelles-et-freebox-delta-comment-heberger-votre-premiere-page-web/).
|
||||||
|
- Know how to configure [NAT rules on a router and assign DHCP leases](/general/networking/nat)
|
||||||
|
- Know how to configure the [DNS zone of a domain name](/general/networking/dns)
|
||||||
|
::
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="/img/serveex/serveex.svg" align="center" width="700">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
The goal is to be easily deployable and easy to migrate, so here is its structure:
|
||||||
|
|
||||||
|
### The Core of the Server
|
||||||
|
|
||||||
|
:::div{class="relative"}
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px}
|
||||||
|
:::
|
||||||
|
|
||||||
|
::card-group
|
||||||
|
::card{icon="i-logos-debian" title="Operating System" to="/serveex/core/installation"}
|
||||||
|
Install and configure Debian 13
|
||||||
|
::
|
||||||
|
|
||||||
|
::card{icon="i-logos-docker-icon" title="Using apps container" to="/serveex/core/docker"}
|
||||||
|
Install Docker
|
||||||
|
::
|
||||||
|
|
||||||
|
::card
|
||||||
|
---
|
||||||
|
icon: i-carbon-container-registry
|
||||||
|
title: Container manager
|
||||||
|
to: "/serveex/core/docker#install-dockge-to-manage-and-deploy-containers"
|
||||||
|
ui:
|
||||||
|
icon: text-[#74C2FF]
|
||||||
|
---
|
||||||
|
Install and deploy Dockge
|
||||||
|
::
|
||||||
|
|
||||||
|
::card
|
||||||
|
---
|
||||||
|
icon: i-simple-icons-wireguard
|
||||||
|
title: VPN
|
||||||
|
to: /serveex/core/wireguard
|
||||||
|
ui:
|
||||||
|
icon: text-[#88171A]
|
||||||
|
---
|
||||||
|
Install and deploy WireGuard
|
||||||
|
::
|
||||||
|
|
||||||
|
::card{icon="i-noto-globe-showing-americas" title="Reverse Proxy" to="/serveex/core/swag"}
|
||||||
|
Expose your services with SWAG
|
||||||
|
::
|
||||||
|
::
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
:::div{class="relative"}
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px}
|
||||||
|
:::
|
||||||
|
|
||||||
|
::card-group
|
||||||
|
|
||||||
|
|
||||||
|
::card{icon="i-noto-locked-with-key" title="Forward Auth" to="/serveex/security/tinyauth"}
|
||||||
|
Install and deploy TinyAuth
|
||||||
|
::
|
||||||
|
|
||||||
|
::card{icon="i-noto-identification-card" title="Passwordless SSO" to="/serveex/security/pocket-id"}
|
||||||
|
Install and deploy Pocket ID
|
||||||
|
::
|
||||||
|
|
||||||
|
::card{icon="i-logos-cloudflare-icon" title="Zero Trust" to="/serveex/security/cloudflare"}
|
||||||
|
Install and deploy Cloudflared
|
||||||
|
::
|
||||||
|
::
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
:::div{class="relative"}
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px}
|
||||||
|
:::
|
||||||
|
|
||||||
|
::card-group
|
||||||
|
::card
|
||||||
|
---
|
||||||
|
icon: i-solar-pulse-linear
|
||||||
|
title: Service Status
|
||||||
|
to: /serveex/monitoring/uptime-kuma
|
||||||
|
ui:
|
||||||
|
icon: text-[#5CDD8B]
|
||||||
|
---
|
||||||
|
Install and deploy Uptime-Kuma
|
||||||
|
::
|
||||||
|
|
||||||
|
::card
|
||||||
|
---
|
||||||
|
icon: i-lucide-logs
|
||||||
|
title: Log Management
|
||||||
|
to: /serveex/monitoring/dozzle
|
||||||
|
ui:
|
||||||
|
icon: text-[#FFA600]
|
||||||
|
---
|
||||||
|
Install and deploy Dozzle
|
||||||
|
::
|
||||||
|
|
||||||
|
::card{icon="i-noto-rabbit" title="Connection Management" to="/serveex/monitoring/speedtest-tracker"}
|
||||||
|
Install and deploy Speedtest Tracker
|
||||||
|
::
|
||||||
|
|
||||||
|
::card
|
||||||
|
---
|
||||||
|
icon: i-lucide-chart-column-decreasing
|
||||||
|
title: Resource Status
|
||||||
|
to: /serveex/monitoring/beszel
|
||||||
|
ui:
|
||||||
|
icon: text-[#747bff]
|
||||||
|
---
|
||||||
|
Install and deploy Beszel
|
||||||
|
::
|
||||||
|
|
||||||
|
::card
|
||||||
|
---
|
||||||
|
icon: i-lucide-circle-power
|
||||||
|
title: Wake on Lan
|
||||||
|
to: /serveex/monitoring/upsnap
|
||||||
|
ui:
|
||||||
|
icon: text-[#5BDAFD]
|
||||||
|
---
|
||||||
|
Install and deploy UpSnap
|
||||||
|
::
|
||||||
|
::
|
||||||
|
|
||||||
|
### Media
|
||||||
|
|
||||||
|
:::div{class="relative"}
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px}
|
||||||
|
:::
|
||||||
|
|
||||||
|
::card-group
|
||||||
|
::card
|
||||||
|
---
|
||||||
|
icon: i-simple-icons-jellyfin
|
||||||
|
title: Media
|
||||||
|
to: /serveex/media/jellyfin
|
||||||
|
ui:
|
||||||
|
icon: text-[#00A4DC]
|
||||||
|
---
|
||||||
|
Install and deploy Jellyfin
|
||||||
|
::
|
||||||
|
|
||||||
|
::card
|
||||||
|
---
|
||||||
|
icon: i-cbi-qbittorrent
|
||||||
|
title: Seedbox
|
||||||
|
to: /serveex/media/qbittorrent
|
||||||
|
ui:
|
||||||
|
icon: text-[#2F67BA]
|
||||||
|
---
|
||||||
|
Install and deploy qBittorrent
|
||||||
|
::
|
||||||
|
|
||||||
|
::card
|
||||||
|
---
|
||||||
|
icon: i-cbi-radarr
|
||||||
|
title: Automation
|
||||||
|
to: /serveex/media/servarr
|
||||||
|
ui:
|
||||||
|
icon: text-[#FFCB3D]
|
||||||
|
---
|
||||||
|
Install and deploy the Servarr stack
|
||||||
|
::
|
||||||
|
::
|
||||||
|
|
||||||
|
### Cloud Drive & Photos
|
||||||
|
|
||||||
|
:::div{class="relative"}
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px}
|
||||||
|
:::
|
||||||
|
|
||||||
|
::card-group
|
||||||
|
::card
|
||||||
|
---
|
||||||
|
icon: i-cib-nextcloud
|
||||||
|
title: Drive
|
||||||
|
to: /serveex/cloud/nextcloud
|
||||||
|
ui:
|
||||||
|
icon: text-[#0082C9]
|
||||||
|
---
|
||||||
|
Install and deploy Nextcloud
|
||||||
|
::
|
||||||
|
|
||||||
|
::card
|
||||||
|
---
|
||||||
|
icon: i-simple-icons-immich
|
||||||
|
title: Photos
|
||||||
|
to: /serveex/cloud/immich
|
||||||
|
ui:
|
||||||
|
icon: text-[#4250AF]
|
||||||
|
---
|
||||||
|
Install and deploy Immich
|
||||||
|
::
|
||||||
|
::
|
||||||
|
|
||||||
|
### Files & Sharing
|
||||||
|
|
||||||
|
:::div{class="relative"}
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px}
|
||||||
|
:::
|
||||||
|
|
||||||
|
::card-group
|
||||||
|
::card{icon="i-noto-open-file-folder" title="File Explorer" to="/serveex/files/file-browser-quantum"}
|
||||||
|
Install and deploy File Browser Quantum
|
||||||
|
::
|
||||||
|
|
||||||
|
::card
|
||||||
|
---
|
||||||
|
icon: i-carbon-share
|
||||||
|
title: Sharing
|
||||||
|
to: /serveex/files/pingvin
|
||||||
|
ui:
|
||||||
|
icon: text-[#46509E]
|
||||||
|
---
|
||||||
|
Install and deploy Pingvin
|
||||||
|
::
|
||||||
|
::
|
||||||
|
|
||||||
|
### Development Tools
|
||||||
|
|
||||||
|
:::div{class="relative"}
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px}
|
||||||
|
:::
|
||||||
|
|
||||||
|
::card-group
|
||||||
|
::card{icon="i-logos-visual-studio-code" title="Visual Studio Code" to="/serveex/development/code-server"}
|
||||||
|
Install and deploy code-server
|
||||||
|
::
|
||||||
|
|
||||||
|
::card
|
||||||
|
---
|
||||||
|
icon: i-simple-icons-forgejo
|
||||||
|
title: Git Repository
|
||||||
|
to: /serveex/development/forgejo
|
||||||
|
ui:
|
||||||
|
icon: text-[#FB923C]
|
||||||
|
---
|
||||||
|
Install and deploy Forgejo
|
||||||
|
::
|
||||||
|
|
||||||
|
::card{icon="i-noto-hammer-and-wrench" title="Tools" to="/serveex/development/it-tools"}
|
||||||
|
Install and deploy IT Tools
|
||||||
|
::
|
||||||
|
::
|
||||||
|
|
||||||
|
### Useful Applications
|
||||||
|
|
||||||
|
:::div{class="relative"}
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px}
|
||||||
|
:::
|
||||||
|
|
||||||
|
::card-group
|
||||||
|
::card
|
||||||
|
---
|
||||||
|
icon: i-cbi-adguard
|
||||||
|
title: Ad-blocking DNS and Filters
|
||||||
|
to: /serveex/apps/adguard
|
||||||
|
ui:
|
||||||
|
icon: text-[#68BC71]
|
||||||
|
---
|
||||||
|
Install and deploy AdGuard Home
|
||||||
|
::
|
||||||
|
|
||||||
|
::card
|
||||||
|
---
|
||||||
|
icon: i-cbi-bitwarden
|
||||||
|
title: Password Manager
|
||||||
|
to: /serveex/apps/vaultwarden
|
||||||
|
ui:
|
||||||
|
icon: text-[#175DDC]
|
||||||
|
---
|
||||||
|
Install and deploy Vaultwarden
|
||||||
|
::
|
||||||
|
::
|
||||||
|
|
||||||
|
### Advanced
|
||||||
|
|
||||||
|
:::div{class="relative"}
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px}
|
||||||
|
:::
|
||||||
|
|
||||||
|
::card-group
|
||||||
|
::card{icon="i-noto-key" title="SSO & MFA" to="/serveex/advanced/authentik"}
|
||||||
|
Install and deploy Authentik
|
||||||
|
::
|
||||||
|
|
||||||
|
::card{icon="i-noto-crystal-ball" title="Multi-host Docker manager" to="/serveex/advanced/arcane"}
|
||||||
|
Install and deploy Arcane
|
||||||
|
::
|
||||||
|
::
|
||||||
|
|
||||||
|
## Coming Soon
|
||||||
|
|
||||||
|
- Homepage, to have all your services at a glance and access them easily
|
||||||
|
- Zensical, how to write and organize your own documentation
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
title: Server core
|
||||||
|
icon: i-lucide-server-cog
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
---
|
||||||
|
title: Debian 13
|
||||||
|
description: Step-by-step guide to install Debian 13 on a home server and set up SSH access, essential packages, and a ready-to-use base system.
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px zIndex=60}
|
||||||
|
|
||||||
|
[Debian 13 "Trixie"](https://www.debian.org/) is the base this whole guide sits on. It's a plain, boring, stable Linux, and for a homelab that's exactly the point: package versions stay frozen for the life of the release, security patches keep coming for about five years counting LTS, so the machine keeps running for years instead of needing a rebuild every few months.
|
||||||
|
|
||||||
|
The other reason is that it stays out of your way. Appliance systems like Unraid, TrueNAS or Synology's DSM put their own layer on top of Linux, and the day you need something their interface doesn't expose, you're stuck working around it. Debian is just a server: you install what you want, where you want, and nothing is hidden behind someone else's UI. It also happens to be what most self-hosted projects target first, so their docs hand you `apt` commands that work as-is, Docker publishes an official Debian repository, and any error message you paste into a search engine already has years of answers behind it. A minimal install is light enough to leave nearly all the RAM and CPU of a small N100 box to your containers.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
::note{icon="" to="/general/linux/cli-basics"}
|
||||||
|
📋 __Prerequisite:__ everything past this point happens in a terminal, so you should be comfortable with the basics: moving around with `cd`, listing with `ls`, editing a file with `nano`, and reading what a command tells you when it fails. If any of that is new, **start with the command line basics** and come back.
|
||||||
|
::
|
||||||
|
|
||||||
|
### BIOS setup
|
||||||
|
|
||||||
|
Press :kbd{value="Del"} or :kbd{value="F2"} right after powering on to open the firmware setup (the boot screen usually says which key it is). Most machines also have a one-shot boot menu, often :kbd{value="F12"}, :kbd{value="F11"} or :kbd{value="F8"}, which lets you boot the USB installer once without touching the permanent boot order. Debian documents the general procedure in its [installation manual](https://www.debian.org/releases/trixie/amd64/ch03s06.en.html), and here is what matters before you install:
|
||||||
|
|
||||||
|
- **Boot mode.** Prefer native UEFI. The important part is that the installer boots in the *same* mode you intend to run the server in, because UEFI uses GPT partitioning while legacy BIOS (and UEFI in CSM mode) uses a DOS partition table, and a mismatch installs the bootloader in the wrong place. Watch out on multi-boot machines: the default boot mode for removable devices is often not the one used for internal disks.
|
||||||
|
- **Secure Boot** can stay enabled. Debian ships a Microsoft-signed shim bootloader, so it boots fine as-is.
|
||||||
|
- **SATA mode** should be `AHCI`, not `RAID` / Intel RST, otherwise Linux may not see your drives at all. Changing this after installing another OS on the same disk will stop that OS from booting, so set it before you start.
|
||||||
|
- **Restore on AC power loss** so the server comes back by itself after an outage instead of waiting for someone to press the button. The setting lives in *Power Management*, *APM Configuration* or *Advanced* depending on the board, under a name like *Restore on AC Power Loss*, *AC Power Recovery*, *After Power Failure*, *AC Back Function* or *S0 state*. Set it to **Power On**, not *Last State*, which would leave the machine off if the outage caught it during a shutdown.
|
||||||
|
- **Wake on LAN**, if you want to power the machine up remotely instead of walking to it. Same *Power Management* menu: set *Wake on LAN*, *Power On By PCI-E/PCI* or *Resume by PCI-E Device* to **Enabled**, then disable *ErP* / *EuP Ready* and *Deep Sleep* / *Deep Sx*, which cut power to the network card once the machine is off and would keep it deaf to the magic packet. Debian also needs the card told to listen, see [Going further](#going-further).
|
||||||
|
- **Virtualization** (`VT-x` / `AMD-V`, plus `VT-d` for passthrough) costs nothing to turn on now and saves a trip back into the BIOS the day you want to run a VM. Docker itself doesn't need it on Linux.
|
||||||
|
|
||||||
|
::note
|
||||||
|
If you're dual-booting Windows, disable its *fast startup*: it leaves the filesystem in a state Linux can corrupt, and Windows Update likes to silently turn it back on.
|
||||||
|
::
|
||||||
|
|
||||||
|
### Download the ISO and write it to a USB stick
|
||||||
|
|
||||||
|
::steps{level="4"}
|
||||||
|
#### Download the netinst image
|
||||||
|
|
||||||
|
Grab the `amd64` **netinst** image from [debian.org](https://www.debian.org/download.en.html). It's around 700 MB and pulls the rest of the packages from the network during install, which is what you want on a server that's plugged into ethernet: you get current packages instead of installing from a months-old snapshot and patching afterwards. The full DVD images only make sense if the machine has no network during setup.
|
||||||
|
|
||||||
|
#### Write it with Rufus
|
||||||
|
|
||||||
|
On Windows, write it with [Rufus](https://rufus.ie/) (portable, no install needed). Plug in a USB stick of 2 GB or more, keeping in mind **it will be wiped entirely**, then:
|
||||||
|
|
||||||
|
- **Device**: your USB stick. Check the capacity twice, Rufus happily writes to the wrong drive if you let it.
|
||||||
|
- **Boot selection**: `SELECT`, then pick the Debian ISO you just downloaded.
|
||||||
|
- **Partition scheme**: this has to match the boot mode you set in the BIOS above. `GPT` for UEFI, `MBR` only if you're staying on legacy/CSM. The target system field follows automatically.
|
||||||
|
- Leave the format options at their defaults, then hit `START`. If Rufus asks how to write the image, keep the recommended *ISO Image mode*.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
_Screenshot from [this bootable USB guide on DEV Community](https://dev.to/devops2808/how-to-create-bootable-usb-installer-for-debian-12-4f66)._
|
||||||
|
|
||||||
|
Writing takes a few minutes.
|
||||||
|
|
||||||
|
#### Done!
|
||||||
|
::
|
||||||
|
|
||||||
|
### Install Debian
|
||||||
|
|
||||||
|
Boot the USB stick (one-shot boot menu from the BIOS section) and pick **Install**, the text installer. The goal here is a minimal headless server: no desktop, no graphical session, nothing but a shell reachable over SSH. The screen and keyboard you're using right now are only needed for this one install, after that the machine runs blind in a corner. The [official installation guide](https://www.debian.org/releases/trixie/amd64/ch06s03.en.html) documents every screen.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
::steps{level="4"}
|
||||||
|
#### Language, country, keyboard
|
||||||
|
|
||||||
|
Nothing special. The keyboard layout is the one you're physically typing on, which is easy to get wrong if you picked English but type on AZERTY.
|
||||||
|
|
||||||
|
#### Network and hostname
|
||||||
|
|
||||||
|
A wired connection gets configured over DHCP by itself. When it asks for a **hostname**, give the machine a real name (`serveex`, `nas`...), you'll see it in every SSH prompt afterwards. The **domain** can be left empty, or set to something like `lan` if you already use one at home.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Root password and user account
|
||||||
|
|
||||||
|
Leave the **root password empty**. Debian then disables the root account, installs `sudo` and puts your user in it, which is the safer default and saves you a round of setup later.
|
||||||
|
|
||||||
|
Then create your user: full name, username, password. This is the account you'll SSH into. Avoid `admin` as a username, it's reserved on Debian and the installer will reject it.
|
||||||
|
|
||||||
|
#### Clock
|
||||||
|
|
||||||
|
Confirm the timezone guessed from your country.
|
||||||
|
|
||||||
|
#### Partitioning
|
||||||
|
|
||||||
|
*Guided, use entire disk* on the system drive, then *All files in one partition*, which gives you one big `/` plus a swap partition. Separate `/home` or `/var` partitions buy you very little here and mostly guarantee that one fills up while the others sit half empty. Pick LVM only if you already know you want snapshots or to grow volumes later. Your data disks are not touched at this stage, you'll mount them afterwards.
|
||||||
|
|
||||||
|
Finish with *Finish partitioning and write changes to disk*, then confirm with *Yes*: this is the point of no return for that disk.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
::tip{icon="" to="/general/linux/filesystem"}
|
||||||
|
✨ __Tip:__ what actually lives on that one partition, and why `/srv/docker` is where this guide puts every stack, is covered in **folders and partitions**.
|
||||||
|
::
|
||||||
|
|
||||||
|
#### Mirror and surveys
|
||||||
|
|
||||||
|
Answer *No* to *Scan another installation medium?*, everything else comes from the network. For the mirror, pick any one in your country, or `deb.debian.org` which routes to a nearby one automatically, and leave the HTTP proxy field empty unless you actually have one. The popularity contest (anonymous package statistics) is yes or no, no consequence either way.
|
||||||
|
|
||||||
|
#### Software selection (tasksel)
|
||||||
|
|
||||||
|
The screen that actually decides whether your server stays minimal. Uncheck **everything**, in particular `Debian desktop environment` and `GNOME`, which are ticked by default and would drag in gigabytes of packages plus a graphical session you will never display. Keep exactly two boxes: **`SSH server`**, your only way in from now on, and **`standard system utilities`**, which the rest of this guide assumes.
|
||||||
|
|
||||||
|
::warning
|
||||||
|
Boxes are ticked and unticked with :kbd{value="Space"}, never :kbd{value="Enter"}. :kbd{value="Enter"} validates the whole screen and moves on, so pressing it on the desktop entry installs GNOME instead of removing it, and you get a graphical server you then have to strip by hand. Use :kbd{value="Tab"} to reach `Continue` once the two boxes above are the only ones checked.
|
||||||
|
::
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### GRUB
|
||||||
|
|
||||||
|
Install it on the disk you just partitioned (`/dev/sda`, `/dev/nvme0n1`...), not on a partition.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Done!
|
||||||
|
::
|
||||||
|
|
||||||
|
::note
|
||||||
|
Before rebooting, take a minute to give the server a **fixed address** in your router. Everything that comes later points at it: your SSH shortcuts, the reverse proxy, the bookmarks to each service. On a plain DHCP lease that address changes on its own eventually and all of it breaks at once.
|
||||||
|
|
||||||
|
The clean way is a DHCP reservation, which ties the address to the server's MAC address while leaving the router in charge of the addressing. See [NAT & DHCP](/general/networking/nat) for where to find it in your router's interface.
|
||||||
|
::
|
||||||
|
|
||||||
|
_Installer screenshots from [howtoforge.com's Debian minimal server guide](https://www.howtoforge.com/tutorial/debian-minimal-server/)._
|
||||||
|
|
||||||
|
### Connect over SSH
|
||||||
|
|
||||||
|
The server has no screen from now on, everything goes through SSH. These steps get you in, then make sure nobody else can be.
|
||||||
|
|
||||||
|
::steps{level="4"}
|
||||||
|
#### Connect from another machine
|
||||||
|
|
||||||
|
Remove the USB stick and reboot. The address to use is the one you reserved in the router just before, `192.168.1.42` in the examples below.
|
||||||
|
|
||||||
|
Everything from here happens from another machine on your local network, not on the server. Windows and macOS both ship an SSH client, so there's nothing to install: open **PowerShell** on Windows, or **Terminal** on macOS, and type the same command.
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
ssh [email protected]
|
||||||
|
```
|
||||||
|
|
||||||
|
The first connection asks you to confirm the server's fingerprint, which is normal, answer `yes`. It gets stored in `~/.ssh/known_hosts` and you won't be asked again.
|
||||||
|
|
||||||
|
::note
|
||||||
|
If the connection is refused, the `SSH server` box was probably left unchecked at the tasksel screen. Plug a screen back in, log in locally and run `sudo apt install openssh-server`.
|
||||||
|
::
|
||||||
|
|
||||||
|
The screen and keyboard are no longer needed. Unplug them, the machine can go live in its corner.
|
||||||
|
|
||||||
|
#### Log in with a key instead of a password
|
||||||
|
|
||||||
|
Passwords over SSH get brute-forced the moment the port is reachable from outside, and typing one on every connection gets old fast. Still on the other machine, generate a key if you don't already have one:
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
ssh-keygen -t ed25519
|
||||||
|
```
|
||||||
|
|
||||||
|
Press :kbd{value="Enter"} to accept the default path, and set a passphrase (it protects the key file itself, your system will remember it after the first unlock). Then copy the public half to the server. Windows has no `ssh-copy-id`, so it pushes the key over the connection instead:
|
||||||
|
|
||||||
|
::code-group
|
||||||
|
```bash [macOS]
|
||||||
|
ssh-copy-id [email protected]
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash [Windows]
|
||||||
|
type $env:USERPROFILE\.ssh\id_ed25519.pub | ssh [email protected] "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash [Linux]
|
||||||
|
ssh-copy-id [email protected]
|
||||||
|
```
|
||||||
|
::
|
||||||
|
|
||||||
|
It asks for your password one final time. Reconnect to check that it no longer does:
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
ssh [email protected]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Close the door behind you
|
||||||
|
|
||||||
|
Once the key works, turn off password logins and direct root access. On the server:
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo nano /etc/ssh/sshd_config.d/hardening.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
```properties [hardening.conf]
|
||||||
|
PasswordAuthentication no
|
||||||
|
PermitRootLogin no
|
||||||
|
KbdInteractiveAuthentication no
|
||||||
|
```
|
||||||
|
|
||||||
|
A file in `sshd_config.d/` is read on top of the main config, so your changes survive a package upgrade rewriting `/etc/ssh/sshd_config`. Apply it:
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo systemctl restart ssh
|
||||||
|
```
|
||||||
|
|
||||||
|
::warning
|
||||||
|
Keep your current SSH session open while you test. Open a **second** terminal and connect again: if the key stopped working, the still-open session is your way back in to fix the config. Close it before you've checked and a typo locks you out of your own server, leaving the screen and keyboard as the only way back.
|
||||||
|
::
|
||||||
|
|
||||||
|
::note
|
||||||
|
The door is now closed for every other machine too, including the next one you'll want to connect from. To let a new one in, set `PasswordAuthentication yes` back in `hardening.conf`, restart SSH, run the two key steps above from that machine, then set it to `no` again and restart SSH one last time.
|
||||||
|
::
|
||||||
|
|
||||||
|
#### Done!
|
||||||
|
::
|
||||||
|
|
||||||
|
### Wake the server up remotely
|
||||||
|
|
||||||
|
A machine that runs 24/7 for two hours of actual use burns power, spins fans and wears drives for nothing. Wake on LAN lets you shut it down properly when you're done and bring it back in a few seconds without walking to it: the network card stays powered in standby, listening for one specific broadcast (the *magic packet*) carrying the server's MAC address, and switches the machine on when it sees it. Handy for a backup target you only need at night, or a media server nobody watches during the day.
|
||||||
|
|
||||||
|
Two conditions before you start: the machine has to be wired to ethernet, WiFi cards almost never support this, and the packet has to be sent from the same local network, since a broadcast doesn't cross a router, unless you use a dedicated app and port-forwarding rules on your router. The BIOS side was covered in [BIOS setup](#bios-setup), here is the Debian side.
|
||||||
|
|
||||||
|
::steps{level="4"}
|
||||||
|
#### Find the interface and its MAC address
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
ip -br link
|
||||||
|
```
|
||||||
|
|
||||||
|
You get something like `enp1s0 UP aa:bb:cc:dd:ee:ff`. Keep both: the interface name for the commands below, the MAC address for the machine that will send the packet.
|
||||||
|
|
||||||
|
#### Check the card supports it
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo apt install ethtool
|
||||||
|
sudo ethtool enp1s0 | grep -i wake
|
||||||
|
```
|
||||||
|
|
||||||
|
The answer looks like `Supports Wake-on: pumbg` then `Wake-on: d`. The letter that matters is **g**, for magic packet. If the *Supports* line doesn't have it, the card can't do it and there's nothing to configure. `Wake-on: d` simply means disabled, which the next step fixes.
|
||||||
|
|
||||||
|
#### Turn it on
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo ethtool -s enp1s0 wol g
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the check again, `Wake-on` should now be `g`. This setting is reset at every boot, so it needs to be reapplied automatically.
|
||||||
|
|
||||||
|
#### Make it survive reboots
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo nano /etc/systemd/system/wol.service
|
||||||
|
```
|
||||||
|
|
||||||
|
```ini [wol.service]
|
||||||
|
[Unit]
|
||||||
|
Description=Enable Wake on LAN
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/sbin/ethtool -s enp1s0 wol g
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=basic.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now wol.service
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Wake it up
|
||||||
|
|
||||||
|
Shut the server down with `sudo poweroff`, then send the magic packet from another machine on the network. On macOS and Linux, the `wakeonlan` package does it in one command:
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
wakeonlan aa:bb:cc:dd:ee:ff
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows has no built-in sender, so the simplest route there is a phone app: any of the free *Wake on LAN* apps takes the MAC address and works the same way. The server should start within a couple of seconds.
|
||||||
|
|
||||||
|
#### Done!
|
||||||
|
::
|
||||||
|
|
||||||
|
::note
|
||||||
|
Waking it from outside your home is another story, and going through a VPN doesn't help if that VPN runs on the server itself: the tunnel is down for as long as the machine is off. The way around it is to forward a UDP port on the router (7 or 9, the usual Wake on LAN ports) to the server, then send the packet to your public address from an app that handles it, [WolOn](https://wolon.app/) for instance. The [NAT rule](/general/networking/nat) is a plain one, `UDP 9` from the outside to `192.168.1.42:9` on the inside. The router still has to point that IP at the right MAC address while the machine is off, which is why some of them expose a static ARP entry, or a Wake on LAN button of their own that saves you the port forward entirely. Worth checking your router first.
|
||||||
|
::
|
||||||
|
|
||||||
|
### Keep it up to date
|
||||||
|
|
||||||
|
Debian doesn't update itself. Every month or so, or whenever you think about it, four commands over SSH:
|
||||||
|
|
||||||
|
::steps{level="4"}
|
||||||
|
#### Refresh the package lists
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo apt update
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing is installed at this point, `apt` only asks the mirrors what's available and tells you how many packages are behind.
|
||||||
|
|
||||||
|
#### Apply the updates
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo apt full-upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
`full-upgrade` is preferred over plain `upgrade` because it accepts removing a package when that's what it takes to move another one forward, which does happen on a server that lives for years. Read the summary before answering yes, it lists exactly what gets removed.
|
||||||
|
|
||||||
|
#### Clean up behind them
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo apt autoremove --purge
|
||||||
|
```
|
||||||
|
|
||||||
|
Every kernel update leaves the previous one installed, and they pile up if nobody clears them out. Do this every single time, not once in a while. `--purge` also drops the config files of the packages being removed.
|
||||||
|
|
||||||
|
#### Reboot if the kernel moved
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
A kernel or libc update only takes effect after a restart. Everything else applies immediately, so this is only needed when the upgrade touched one of those, and it's worth planning for a moment when nothing depends on the machine.
|
||||||
|
|
||||||
|
#### Done!
|
||||||
|
::
|
||||||
|
|
||||||
|
::tip
|
||||||
|
If you don't need to watch what's going on, the first three steps fit on one line, `&&` stopping the chain as soon as one of them fails:
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo apt update && sudo apt full-upgrade -y && sudo apt autoremove --purge -y
|
||||||
|
```
|
||||||
|
|
||||||
|
`-y` answers yes to every question, including the day an upgrade proposes to remove something you would rather have kept, so keep it for routine rounds. Append `&& sudo reboot` to get the restart out of the way too.
|
||||||
|
::
|
||||||
|
|
||||||
|
For security patches without having to think about it, `sudo apt install unattended-upgrades` then `sudo dpkg-reconfigure -plow unattended-upgrades` applies them on its own every night. Note that all of this only covers the system: your containers are updated separately, from Dockge.
|
||||||
|
|
||||||
|
### Going further
|
||||||
|
|
||||||
|
- [Everything About Remote Console Access (SSH)](https://www.digitalocean.com/community/tutorials/ssh-essentials-working-with-ssh-servers-clients-and-keys)
|
||||||
|
- Optional - [UPS Client in Case of Power Outage](https://www.sindastra.de/p/2078/how-to-connect-linux-server-to-synology-ups-server) / [also here](https://www.reddit.com/r/synology/comments/gtkjam/use_synology_nas_as_ups_server_to_safely_power/)
|
||||||
|
|
||||||
|
::tip{icon="" to="/general/linux/handy-tools"}
|
||||||
|
✨ __Tip:__ a handful of terminal tools worth adding on top of a minimal install, `btop`, `duf`, `ufw` and a few more, are covered in **handy CLI tools**.
|
||||||
|
::
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
---
|
||||||
|
title: Docker
|
||||||
|
description: Install Docker and Dockge on Debian to deploy and manage self-hosted services with simple container stacks.
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
:ellipsis{left=0px width=40rem top=10rem blur=140px zIndex=60}
|
||||||
|
|
||||||
|
Every app in this guide, [Jellyfin](/serveex/media/jellyfin), [Vaultwarden](/serveex/apps/vaultwarden), [Immich](/serveex/cloud/immich), to name a few, comes with its own list of dependencies, its own version of Python or Node, its own quirks. Installing all of that directly on Debian works for a while, until two apps want a different version of the same library, or removing one leaves files scattered across the system with no clean way back.
|
||||||
|
|
||||||
|
A **container** sidesteps the problem: it packages an app together with everything it needs to run, isolated from the rest of the system and from every other container. Starting one doesn't touch Debian's own packages, and removing it is a single command that leaves nothing behind. It's not a virtual machine either, there's no second operating system to boot or resources to pre-allocate: a container shares the host's kernel and starts in about a second, using only the RAM and CPU the app inside it actually needs.
|
||||||
|
|
||||||
|
**Docker** is the tool that builds, starts and manages these containers. Point it at an *image*, a ready-made snapshot of an app maintained by its developers, and it downloads it and runs it in one command. The rest of Serveex is built entirely on it: every app from here on is one Docker container, or a handful of them working together.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Install Docker
|
||||||
|
::steps{level="3"}
|
||||||
|
### Add the Docker repository and GPG key
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
# Add Docker's official GPG key:
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install ca-certificates curl
|
||||||
|
sudo install -m 0755 -d /etc/apt/keyrings
|
||||||
|
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||||
|
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||||
|
|
||||||
|
# Add the repository to Apt sources:
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||||
|
sudo apt-get update
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install the packages
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Done!
|
||||||
|
::
|
||||||
|
|
||||||
|
**More options:** [Install Docker for Debian 13](https://docs.docker.com/engine/install/debian/)
|
||||||
|
|
||||||
|
::note
|
||||||
|
|
||||||
|
From here on, we assume the stacks are installed in the `/srv/docker` folder, created using the command:
|
||||||
|
```bash [Terminal]
|
||||||
|
sudo mkdir /srv/docker
|
||||||
|
```
|
||||||
|
::
|
||||||
|
|
||||||
|
## Install [Dockge](https://github.com/louislam/dockge) to manage and deploy containers
|
||||||
|
[Dockge](https://github.com/louislam/dockge) is a web tool to create, configure, launch, and manage Docker containers. It's a simple, intuitive interface that’s lighter and easier for beginners than using the CLI or Portainer.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
::file-tree
|
||||||
|
---
|
||||||
|
label: File structure we will create
|
||||||
|
tree:
|
||||||
|
/:
|
||||||
|
- srv:
|
||||||
|
- docker:
|
||||||
|
- dockge:
|
||||||
|
- compose.yaml
|
||||||
|
---
|
||||||
|
::
|
||||||
|
|
||||||
|
::steps{level="4"}
|
||||||
|
#### Create the stack folder
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
cd /srv/docker
|
||||||
|
sudo mkdir dockge
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Create the compose file
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
cd /srv/docker/dockge
|
||||||
|
sudo nano compose.yaml
|
||||||
|
```
|
||||||
|
Paste the following:
|
||||||
|
|
||||||
|
```yaml [compose.yaml]
|
||||||
|
---
|
||||||
|
services:
|
||||||
|
dockge:
|
||||||
|
image: louislam/dockge:1
|
||||||
|
restart: unless-stopped
|
||||||
|
container_name: dockge
|
||||||
|
ports:
|
||||||
|
- 3555:5001 # LAN-accessible port will be 3555
|
||||||
|
environment:
|
||||||
|
- DOCKER_HOST=tcp://docker-socket-proxy:2375
|
||||||
|
- DOCKGE_STACKS_DIR=/srv/docker
|
||||||
|
volumes:
|
||||||
|
- /srv/docker/dockge/data:/app/data
|
||||||
|
- /srv/docker:/srv/docker
|
||||||
|
networks:
|
||||||
|
- dockge-internal
|
||||||
|
depends_on:
|
||||||
|
- docker-socket-proxy
|
||||||
|
|
||||||
|
docker-socket-proxy:
|
||||||
|
image: lscr.io/linuxserver/socket-proxy:latest
|
||||||
|
container_name: docker-socket-proxy-dockge
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
networks:
|
||||||
|
- dockge-internal
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
environment:
|
||||||
|
- CONTAINERS=1
|
||||||
|
- IMAGES=1
|
||||||
|
- NETWORKS=1
|
||||||
|
- VOLUMES=1
|
||||||
|
- EXEC=1
|
||||||
|
- INFO=1
|
||||||
|
- SYSTEM=1
|
||||||
|
- POST=1
|
||||||
|
- ALLOW_START=1
|
||||||
|
- ALLOW_STOP=1
|
||||||
|
- ALLOW_RESTARTS=1
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /run
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dockge-internal:
|
||||||
|
name: dockge-internal
|
||||||
|
```
|
||||||
|
|
||||||
|
::warning
|
||||||
|
Dockge needs access to the Docker API to manage every other stack on this server, which is effectively root access to your host. Instead of mounting `/var/run/docker.sock` directly, this config sits **Docker Socket Proxy** in front of it, only allowing the specific permissions Dockge needs (containers, images, networks, volumes, exec, lifecycle actions), on their own internal network. Dockge has no built-in login by default, so never expose port `3555` beyond your LAN without putting it behind [TinyAuth](/serveex/security/tinyauth) or [Authentik](/serveex/advanced/authentik) first.
|
||||||
|
::
|
||||||
|
|
||||||
|
Press :kbd{value="Ctrl+O"}, then :kbd{value="Enter"} to save, and :kbd{value="Ctrl+X"} to exit.
|
||||||
|
|
||||||
|
#### Launch the container
|
||||||
|
|
||||||
|
```bash [Terminal]
|
||||||
|
cd /srv/docker/dockge
|
||||||
|
sudo docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Then go to `http://yourserverip:3555` in your browser to access the login page.
|
||||||
|
|
||||||
|
More info on [Dockge and how to use it](https://github.com/louislam/dockge)
|
||||||
|
|
||||||
|
#### Done!
|
||||||
|
::
|
||||||
|
|
||||||
|
And there you go! Docker and a tool to easily manage your containers are ready!
|
||||||
|
|
||||||
|
## [Watchtower](https://watchtower.nickfedor.com/), to auto-update containers
|
||||||
|
Watchtower is a container that checks for updates and pulls new images automatically, just by adding a label in your containers’ `compose.yaml` files.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
::steps{level="4"}
|
||||||
|
#### Create the stack
|
||||||
|
|
||||||
|
- Open Dockge in your browser
|
||||||
|
- Click `compose`
|
||||||
|
- Name the stack `watchtower`
|
||||||
|
- Paste the config below into the default config area in Dockge
|
||||||
|
|
||||||
|
```yaml [compose.yaml]
|
||||||
|
---
|
||||||
|
services:
|
||||||
|
watchtower:
|
||||||
|
container_name: watchtower
|
||||||
|
image: ghcr.io/nicholas-fedor/watchtower:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Paris
|
||||||
|
- WATCHTOWER_SCHEDULE=${SCHEDULE}
|
||||||
|
- WATCHTOWER_LABEL_ENABLE=true
|
||||||
|
- WATCHTOWER_CLEANUP=true
|
||||||
|
- WATCHTOWER_REMOVE_VOLUMES=true
|
||||||
|
- DOCKER_HOST=tcp://docker-socket-proxy:2375
|
||||||
|
# Discord notifications - uncomment if used
|
||||||
|
#- WATCHTOWER_NOTIFICATIONS=slack
|
||||||
|
#- WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER=Watchtower
|
||||||
|
#- WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL=${WH_URL}
|
||||||
|
networks:
|
||||||
|
- watchtower-internal
|
||||||
|
depends_on:
|
||||||
|
- docker-socket-proxy
|
||||||
|
|
||||||
|
docker-socket-proxy:
|
||||||
|
image: lscr.io/linuxserver/socket-proxy:latest
|
||||||
|
container_name: docker-socket-proxy-watchtower
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
networks:
|
||||||
|
- watchtower-internal
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
environment:
|
||||||
|
- CONTAINERS=1
|
||||||
|
- IMAGES=1
|
||||||
|
- NETWORKS=1
|
||||||
|
- VOLUMES=1
|
||||||
|
- INFO=1
|
||||||
|
- SYSTEM=1
|
||||||
|
- POST=1
|
||||||
|
- ALLOW_START=1
|
||||||
|
- ALLOW_STOP=1
|
||||||
|
- ALLOW_RESTARTS=1
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /run
|
||||||
|
|
||||||
|
networks:
|
||||||
|
watchtower-internal:
|
||||||
|
name: watchtower-internal
|
||||||
|
```
|
||||||
|
|
||||||
|
::warning
|
||||||
|
|
||||||
|
`WATCHTOWER_REMOVE_VOLUMES=true` deletes a container's anonymous volumes as soon as it's updated. Combined with a `latest` tag, an automatic update can silently wipe data for any app that still stores something in an anonymous (unnamed) volume instead of a bind mount.
|
||||||
|
::
|
||||||
|
|
||||||
|
::note
|
||||||
|
This config sits **Docker Socket Proxy** in front of the Docker API instead of mounting `/var/run/docker.sock` directly, so Watchtower only gets the permissions it actually needs (list/pull images, recreate containers) rather than full root-equivalent access to the host.
|
||||||
|
::
|
||||||
|
|
||||||
|
#### Set your environment variables
|
||||||
|
|
||||||
|
Fill in the `.env` section in Dockge with the following:
|
||||||
|
|
||||||
|
```properties [.env]
|
||||||
|
SCHEDULE=
|
||||||
|
WH_URL=
|
||||||
|
```
|
||||||
|
|
||||||
|
| Property | Value | Examples |
|
||||||
|
|----------------|--------------------------------------------------------------------|----------------------------------------------|
|
||||||
|
| `SCHEDULE` | Cron format | `0 0 6 * * *` (every day at 6 AM) |
|
||||||
|
| `WH_URL` | Your Discord webhook URL - append `/slack` at the end | `https://yourdiscordserver/webhook/slack` |
|
||||||
|
|
||||||
|
#### Enable Watchtower on other containers
|
||||||
|
|
||||||
|
To have Watchtower monitor your other containers, add this to their `compose.yaml`:
|
||||||
|
|
||||||
|
```yaml [compose.yaml]
|
||||||
|
---
|
||||||
|
services:
|
||||||
|
yourapp:
|
||||||
|
# ...
|
||||||
|
labels:
|
||||||
|
- com.centurylinklabs.watchtower.enable=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart the modified stacks.
|
||||||
|
|
||||||
|
#### Done!
|
||||||
|
::
|
||||||
|
|
||||||
|
And that's it! You now have a solid base to start deploying the services you want!
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user