mirror of
https://github.com/goauthentik/authentik
synced 2026-05-07 23:52:38 +02:00
Compare commits
11 Commits
blueprint_
...
sdko/learn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b57dfb261 | ||
|
|
4317f5863d | ||
|
|
b1ddb11761 | ||
|
|
c34b1344e7 | ||
|
|
84e031f3cf | ||
|
|
66617e5af2 | ||
|
|
c193b9558d | ||
|
|
5fef9e74d6 | ||
|
|
efe1ebfe8f | ||
|
|
374115b721 | ||
|
|
eea7d0973c |
@@ -31,6 +31,15 @@ const packageStaticDirectory = resolve(__dirname, "static");
|
||||
const redirectsFile = resolve(packageStaticDirectory, "_redirects");
|
||||
const redirects = await parse(redirectsFile);
|
||||
const redirectsIndex = new RewriteIndex(redirects);
|
||||
const googleAnalyticsPresetOptions =
|
||||
process.env.NODE_ENV === "production"
|
||||
? {
|
||||
googleAnalytics: {
|
||||
trackingID: "G-9MVR9WZFZH",
|
||||
anonymizeIP: true,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
//#region Copy static files
|
||||
|
||||
@@ -72,10 +81,7 @@ export default createDocusaurusConfig({
|
||||
"@docusaurus/preset-classic",
|
||||
|
||||
/** @type {PresetOptions} */ ({
|
||||
googleAnalytics: {
|
||||
trackingID: "G-9MVR9WZFZH",
|
||||
anonymizeIP: true,
|
||||
},
|
||||
...googleAnalyticsPresetOptions,
|
||||
theme: {
|
||||
customCss: [require.resolve("@goauthentik/docusaurus-config/css/index.css")],
|
||||
},
|
||||
|
||||
11
website/docs/core/learning-center/articles.mdx
Normal file
11
website/docs/core/learning-center/articles.mdx
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: All Articles
|
||||
slug: /core/learning-center/articles
|
||||
hide_table_of_contents: true
|
||||
---
|
||||
|
||||
import DocCardList from "@theme/DocCardList";
|
||||
|
||||
Explore every Learning Center article in one place.
|
||||
|
||||
<DocCardList />
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"label": "Customize your instance",
|
||||
"position": 1,
|
||||
"description": "Configure and personalize your authentik instance"
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
title: Customize an authentication flow with HR-only consent
|
||||
sidebar_custom_props:
|
||||
resourceName: Customize an authentication flow with HR-only consent
|
||||
category: Customize your instance
|
||||
learningPaths:
|
||||
- fundamentals-flows
|
||||
- security
|
||||
shortDescription: Build a non-default auth flow, add a Consent stage, and show it only to users in the HR group.
|
||||
difficulty: intermediate
|
||||
resourceType: tutorial
|
||||
estimatedTime: 20 min
|
||||
---
|
||||
|
||||
Create a custom authentication flow, add a Consent stage, and bind a policy so only users in the `HR` group see that stage.
|
||||
|
||||
## Flow objective and outcome
|
||||
|
||||
You will create a dedicated [flow](/core/glossary/?flow) (separate from the default flow), attach a custom [stage](/core/glossary/?stage), and enforce access with a [policy](/core/glossary/?policy). The final behavior is role-based: HR users are shown a consent prompt, while non-HR users skip that step and continue normally.
|
||||
|
||||
Related glossary terms:
|
||||
|
||||
- [Flow](/core/glossary/?flow)
|
||||
- [Stage](/core/glossary/?stage)
|
||||
- [Policy](/core/glossary/?policy)
|
||||
- [Brand](/core/glossary/?brand)
|
||||
|
||||
## Goals
|
||||
|
||||
- Create and use a custom authentication flow.
|
||||
- Add a custom Consent stage to that flow.
|
||||
- Add a policy so the Consent stage appears only for users in the `HR` group.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Administrative access to authentik.
|
||||
- A group named `HR`.
|
||||
- Two test users:
|
||||
- One user in `HR`.
|
||||
- One user not in `HR`.
|
||||
- A non-production sign-in path where you can safely test the custom flow.
|
||||
|
||||
:::info
|
||||
Use separate browser profiles or private windows for your two test users so session cookies do not affect results.
|
||||
:::
|
||||
|
||||
## Step 1: Create and route a custom authentication flow
|
||||
|
||||
### Create the custom flow
|
||||
|
||||
1. Log in to authentik as an administrator and open the authentik Admin interface.
|
||||
2. Navigate to **Flows and Stages** > **Flows**.
|
||||
3. Click **Create** and configure:
|
||||
- **Name**: `Authentication - HR Consent`
|
||||
- **Slug**: `authentication-hr-consent`
|
||||
- **Title**: `Sign in`
|
||||
- **Designation**: `Authentication`
|
||||
4. Click **Create**.
|
||||
|
||||
### Keep testing isolated from your default login path
|
||||
|
||||
1. Navigate to **System** > **Brands** and click **Create**.
|
||||
2. Create a test-only [brand](/core/glossary/?brand) that matches only your test host/domain.
|
||||
3. Under **Default flows** > **Authentication**, select `Authentication - HR Consent`.
|
||||
4. Click **Create**.
|
||||
5. Open the test host/domain and verify authentication uses your new flow.
|
||||
6. After testing, remove the test brand or set its **Authentication** flow back to the previous value.
|
||||
|
||||
:::warning
|
||||
Do not modify or repurpose the built-in default authentication flow for this tutorial.
|
||||
:::
|
||||
|
||||
## Step 2: Add Consent stage, HR policy, and bindings
|
||||
|
||||
### Create the custom Consent stage
|
||||
|
||||
1. Navigate to **Flows and Stages** > **Stages**.
|
||||
2. Create a new stage of type **Consent**:
|
||||
- **Name**: `Consent - HR Notice`
|
||||
- **Mode**: explicit consent
|
||||
- **Consent text**: `HR notice: By continuing, you acknowledge this HR-specific access policy.`
|
||||
3. Click **Create**.
|
||||
|
||||
### Bind the stage to your custom flow
|
||||
|
||||
1. Navigate to **Flows and Stages** > **Flows** and open `Authentication - HR Consent`.
|
||||
2. Open the **Stage Bindings** tab.
|
||||
3. Click **Bind existing stage**.
|
||||
4. In the binding dialog:
|
||||
- **Target**: `Consent - HR Notice`
|
||||
- **Order**: place it after your existing authentication stages and before final completion.
|
||||
5. Click **Create**.
|
||||
|
||||
### Create the HR-only expression policy
|
||||
|
||||
1. Navigate to **Customization** > **Policies**.
|
||||
2. Click **Create**.
|
||||
3. Select policy type **Expression Policy**.
|
||||
4. Configure:
|
||||
- **Name**: `Policy - Show Consent for HR`
|
||||
5. In **Expression**, paste:
|
||||
|
||||
```python
|
||||
# Show the Consent stage only when a pending user exists and is a member of the HR group.
|
||||
pending_user = request.context.get("pending_user")
|
||||
return bool(pending_user and pending_user.groups.filter(name="HR").exists())
|
||||
```
|
||||
|
||||
6. Click **Create**.
|
||||
|
||||
### Bind the policy to the Consent stage binding
|
||||
|
||||
1. Navigate to **Flows and Stages** > **Flows** and open `Authentication - HR Consent`.
|
||||
2. Open the **Stage Bindings** tab.
|
||||
3. Locate `Consent - HR Notice`, then click the caret (`>`) to expand that stage binding.
|
||||
4. Click **Bind existing policy/group/user**.
|
||||
5. In the dialog, choose the **Policy** tab and select `Policy - Show Consent for HR`.
|
||||
6. Click **Create**.
|
||||
|
||||
### Configure stage policy evaluation (required)
|
||||
|
||||
1. Stay on **Flows and Stages** > **Flows** > `Authentication - HR Consent` > **Stage Bindings**.
|
||||
2. Locate `Consent - HR Notice`, then click the caret (`>`) to expand it.
|
||||
3. Open the stage binding edit view.
|
||||
4. Set **Evaluate when flow is planned** to disabled.
|
||||
5. Set **Evaluate when stage is run** to enabled.
|
||||
6. Click **Update**.
|
||||
|
||||
This ensures the policy is evaluated when the flow reaches the Consent stage, after `pending_user` is already set by the identification step.
|
||||
|
||||
## Step 3: Verify the result
|
||||
|
||||
### Test with an HR user
|
||||
|
||||
1. Open the isolated test login path.
|
||||
2. Sign in as a user in `HR`.
|
||||
3. Confirm `Consent - HR Notice` appears.
|
||||
4. Accept consent and complete sign-in.
|
||||
|
||||
### Test with a non-HR user
|
||||
|
||||
1. Open a fresh session.
|
||||
2. Sign in as a user not in `HR`.
|
||||
3. Confirm the consent stage is skipped.
|
||||
4. Confirm sign-in still completes successfully.
|
||||
|
||||
Expected result:
|
||||
|
||||
- Users in `HR` see the custom Consent stage.
|
||||
- Users not in `HR` do not see the stage.
|
||||
- Both user types can still complete sign-in.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Likely cause | Resolution |
|
||||
| -------------------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| Consent stage never appears | The test path is not using `Authentication - HR Consent` | Re-check where your test entry point selects its authentication flow. |
|
||||
| Consent stage appears for everyone | Policy is missing from the stage binding or bound incorrectly | Open `Consent - HR Notice` stage binding and verify `Policy - Show Consent for HR` is attached. |
|
||||
| Consent stage appears for no one | Group check in expression returns false | Confirm group name is exactly `HR` and verify membership for the HR test user. |
|
||||
| Behavior is inconsistent between tests | Session reuse or stale login state | Re-test in separate private sessions and fully sign out between runs. |
|
||||
|
||||
## Security considerations
|
||||
|
||||
- Keep authorization conditions explicit and narrowly scoped.
|
||||
- Use dedicated test paths when introducing new authentication logic.
|
||||
- Test both allowed and denied paths before any production rollout.
|
||||
- Prefer group checks that are easy to audit and maintain.
|
||||
|
||||
## Next steps
|
||||
|
||||
- Add additional stage-level policy checks for other user cohorts.
|
||||
- Extend the consent content lifecycle (review, versioning, legal sign-off).
|
||||
- Build separate custom authentication flows for different risk profiles or entry points.
|
||||
10
website/docs/core/learning-center/index.mdx
Normal file
10
website/docs/core/learning-center/index.mdx
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Learning Center
|
||||
---
|
||||
|
||||
import DocCardList from "@theme/DocCardList";
|
||||
|
||||
Welcome to the authentik Learning Center. Start with a guided path, filter to the exact topic you
|
||||
need, or browse the full article index.
|
||||
|
||||
<DocCardList />
|
||||
4
website/docs/core/learning-center/path/_category_.json
Normal file
4
website/docs/core/learning-center/path/_category_.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"label": "Learning paths",
|
||||
"position": 99
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Fundamentals of authentik flows
|
||||
slug: /core/learning-center/path/fundamentals-flows
|
||||
hide_table_of_contents: true
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import DocCardList from "@theme/DocCardList";
|
||||
|
||||
<DocCardList />
|
||||
10
website/docs/core/learning-center/path/getting-started.mdx
Normal file
10
website/docs/core/learning-center/path/getting-started.mdx
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Getting Started with authentik
|
||||
slug: /core/learning-center/path/getting-started
|
||||
hide_table_of_contents: true
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import DocCardList from "@theme/DocCardList";
|
||||
|
||||
<DocCardList />
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Providers and Protocols
|
||||
slug: /core/learning-center/path/providers-protocols
|
||||
hide_table_of_contents: true
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import DocCardList from "@theme/DocCardList";
|
||||
|
||||
<DocCardList />
|
||||
10
website/docs/core/learning-center/path/security.mdx
Normal file
10
website/docs/core/learning-center/path/security.mdx
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Security Best Practices
|
||||
slug: /core/learning-center/path/security
|
||||
hide_table_of_contents: true
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import DocCardList from "@theme/DocCardList";
|
||||
|
||||
<DocCardList />
|
||||
10
website/docs/core/learning-center/path/users-sources.mdx
Normal file
10
website/docs/core/learning-center/path/users-sources.mdx
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Managing Users and Sources
|
||||
slug: /core/learning-center/path/users-sources
|
||||
hide_table_of_contents: true
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import DocCardList from "@theme/DocCardList";
|
||||
|
||||
<DocCardList />
|
||||
@@ -14,6 +14,8 @@ The most common types are:
|
||||
|
||||
- [**Reference**](./reference.md): this is typically tables or lists of reference information, such as configuration values, or functions, or most commonly APIs.
|
||||
|
||||
- [**Tutorial**](./tutorial-template.mdx): these are comprehensive, step-by-step guides that walk users through completing specific tasks from start to finish. Tutorials are structured around a specific task and goal, are less generic than procedural docs, and focus on learning by doing.
|
||||
|
||||
### Add a new integration
|
||||
|
||||
To add documentation for a new integration (with support level Community or Vendor), please use the integration templates [`service.md`](https://github.com/goauthentik/authentik/blob/main/website/integrations/template/service.md) from our GitHub repo. You can download the template using the following command:
|
||||
|
||||
121
website/docs/developer-docs/docs/templates/tutorial-template.mdx
vendored
Normal file
121
website/docs/developer-docs/docs/templates/tutorial-template.mdx
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: "Tutorial Template"
|
||||
sidebar_custom_props:
|
||||
resourceName: "[Tutorial title]"
|
||||
category: "[Category label]"
|
||||
learningPaths:
|
||||
- "[filterTag from learningPathsConfig.ts]"
|
||||
shortDescription: "[One-line summary shown on cards]"
|
||||
difficulty: beginner
|
||||
resourceType: tutorial
|
||||
estimatedTime: 20 min
|
||||
---
|
||||
|
||||
import TabItem from "@theme/TabItem";
|
||||
import Tabs from "@theme/Tabs";
|
||||
|
||||
# Tutorial: [Tutorial title]
|
||||
|
||||
Write a task-focused tutorial that a reader can complete from start to finish without guessing.
|
||||
|
||||
:::info
|
||||
Before publishing, replace all placeholder values in frontmatter and body text.
|
||||
:::
|
||||
|
||||
## Overview
|
||||
|
||||
[Explain what the reader will achieve, why this task matters, and what success looks like in 2-3 sentences.]
|
||||
|
||||
## Goals
|
||||
|
||||
- [Primary outcome in one sentence]
|
||||
- [Success signal #1]
|
||||
- [Success signal #2]
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Required authentik version]
|
||||
- [Required role/permissions]
|
||||
- [Required external systems or credentials]
|
||||
- [Required baseline configuration]
|
||||
|
||||
## Step 1: [First major task]
|
||||
|
||||
[Briefly explain the purpose of this step.]
|
||||
|
||||
1. **[Action]**: [Instruction].
|
||||
2. **[Action]**: [Instruction].
|
||||
3. **[Action]**: [Instruction].
|
||||
|
||||
:::info
|
||||
[Optional implementation detail, tip, or context that helps avoid mistakes.]
|
||||
:::
|
||||
|
||||
## Step 2: [Second major task]
|
||||
|
||||
[Briefly explain the purpose of this step.]
|
||||
|
||||
<Tabs groupId="deployment-type">
|
||||
<TabItem value="docker" label="Docker" default>
|
||||
|
||||
```shell
|
||||
docker exec -it authentik /bin/bash
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="kubernetes" label="Kubernetes">
|
||||
|
||||
```shell
|
||||
kubectl exec -it deployment/authentik -- /bin/bash
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
</Tabs>
|
||||
|
||||
1. **[Action]**: [Instruction].
|
||||
2. **[Action]**: [Instruction].
|
||||
3. **[Action]**: [Instruction].
|
||||
|
||||
### Example configuration
|
||||
|
||||
```yaml showLineNumbers title="example.yaml"
|
||||
# Include only fields that matter to this tutorial.
|
||||
key: value
|
||||
```
|
||||
|
||||
:::warning
|
||||
[Call out actions that can cause interruptions, downtime, or unexpected behavior.]
|
||||
:::
|
||||
|
||||
## Step 3: Verify the result
|
||||
|
||||
1. **Navigate to**: [Location].
|
||||
2. **Confirm that**: [Expected UI or output].
|
||||
3. **Validate by**: [Real test action].
|
||||
|
||||
[Describe the exact expected end state.]
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Likely cause | Resolution |
|
||||
| ---------- | ------------ | ------------------- |
|
||||
| [Issue #1] | [Cause] | [Action to resolve] |
|
||||
| [Issue #2] | [Cause] | [Action to resolve] |
|
||||
| [Issue #3] | [Cause] | [Action to resolve] |
|
||||
|
||||
## Security considerations
|
||||
|
||||
- [Security consideration #1].
|
||||
- [Security consideration #2].
|
||||
- [Security consideration #3].
|
||||
|
||||
:::danger
|
||||
[Use only when an action can cause irreversible impact, such as data loss.]
|
||||
:::
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Related tutorial or guide].
|
||||
- [Related conceptual documentation].
|
||||
- [Related reference documentation].
|
||||
@@ -5,46 +5,37 @@ title: Writing documentation
|
||||
import TabItem from "@theme/TabItem";
|
||||
import Tabs from "@theme/Tabs";
|
||||
|
||||
Writing documentation for authentik is a great way for both new and experienced users to improve and contribute to the project. We appreciate contributions to our documentation; everything from fixing a typo to adding additional content to writing a completely new topic.
|
||||
|
||||
The [technical documentation](https://docs.goauthentik.io) and our [integration guides](https://integrations.goauthentik.io/) are built, formatted, and tested using `npm`. The commands to build the content locally are defined in the `Makefile` in the root of the repository. Each command is prefixed with `docs-` or `integrations-` and corresponds to an NPM script within the `website` directory.
|
||||
Writing documentation for authentik is one of the fastest ways to improve the project for every user. This guide covers the workflow, quality bar, and content-specific rules used in our docs repositories.
|
||||
|
||||
## Documentation subdomains
|
||||
|
||||
authentik documentation is deployed to different subdomains based on the git branch:
|
||||
authentik documentation is published from different branches to different subdomains:
|
||||
|
||||
| Subdomain | Git Branch | Description |
|
||||
| -------------------------------------------------- | ---------------- | -------------------------------- |
|
||||
| [main.goauthentik.io](https://main.goauthentik.io) | `main` | Latest changes and features |
|
||||
| [next.goauthentik.io](https://next.goauthentik.io) | `next` | Upcoming release content |
|
||||
| [docs.goauthentik.io](https://docs.goauthentik.io) | Current release | Official stable documentation |
|
||||
| version-YYYY-MM.goauthentik.io | Specific release | Historical version documentation |
|
||||
| Subdomain | Git branch | Purpose |
|
||||
| -------------------------------------------------- | --------------- | -------------------------------- |
|
||||
| [main.goauthentik.io](https://main.goauthentik.io) | `main` | Latest in-progress documentation |
|
||||
| [next.goauthentik.io](https://next.goauthentik.io) | `next` | Upcoming release content |
|
||||
| [docs.goauthentik.io](https://docs.goauthentik.io) | Current release | Stable documentation |
|
||||
| version-YYYY-MM.goauthentik.io | Release branch | Historical version documentation |
|
||||
|
||||
## Guidelines
|
||||
## Core principles
|
||||
|
||||
Adhering to the following guidelines will help us get your PRs merged much easier and faster, with fewer edits needed.
|
||||
- Follow the [Style Guide](./style-guide.mdx).
|
||||
- Prefer existing templates over writing from scratch.
|
||||
- Test locally before opening a PR.
|
||||
- Keep links, redirects, and sidebars up to date.
|
||||
|
||||
- Ideally, when you are making contributions to the documentation, you should fork and clone our repo, then [build it locally](#set-up-your-local-build-tools), so that you can test the docs and run the required linting and spell checkers before pushing your PR. While you can do much of the writing and editing within the GitHub UI, you cannot run the required linters from the GitHub UI.
|
||||
|
||||
- After submitting a PR, you can view the Netlify Deploy Preview for the PR on GitHub, to check that your content rendered correctly, links work, etc. This is especially useful when using Docusaurus-specific features in your content.
|
||||
|
||||
- Please refer to our [Style Guide](./style-guide.mdx) for authentik documentation. Here you will learn important guidelines about not capitalizing authentik, how we format our titles and headers, and much more.
|
||||
|
||||
- Remember to use our templates when possible; they are already set up to follow our style guidelines, they make it a lot easier for you (no blank page frights!), and they keep the documentation structure and headings consistent.
|
||||
- [docs templates](./templates/index.md)
|
||||
- [integration guide template](https://integrations.goauthentik.io/applications#add-a-new-application)
|
||||
|
||||
## Setting up a docs development environment
|
||||
## Set up a docs development environment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en) (24 or later)
|
||||
- [Make](https://www.gnu.org/software/make/) (3 or later)
|
||||
- [Node.js](https://nodejs.org/en) (24 or later).
|
||||
- [Make](https://www.gnu.org/software/make/) (3 or later).
|
||||
|
||||
<Tabs defaultValue="macOS">
|
||||
<TabItem value="macOS">
|
||||
|
||||
Install the required dependencies on macOS using Homebrew:
|
||||
Install Node.js with Homebrew:
|
||||
|
||||
```shell
|
||||
brew install node@24
|
||||
@@ -53,106 +44,94 @@ brew install node@24
|
||||
</TabItem>
|
||||
<TabItem value="Linux">
|
||||
|
||||
[Download NodeJS version 24](https://nodejs.org/en/download/current) for your Linux distribution.
|
||||
Install Node.js 24 for your distribution from the [Node.js downloads page](https://nodejs.org/en/download/current).
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="Windows">
|
||||
|
||||
We're currently seeking community input on building the docs in Windows. If you have experience with this setup, please consider contributing to this documentation.
|
||||
We welcome contributions for a fully documented Windows docs workflow. If you have a working setup, please propose updates to this page.
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Clone and fork the authentik repository
|
||||
### Clone the repository
|
||||
|
||||
```shell
|
||||
git clone https://github.com/goauthentik/authentik
|
||||
```
|
||||
|
||||
The documentation, integration guides, API docs, and the code are in the same [GitHub repo](https://github.com/goauthentik/authentik), so if you have cloned and forked the repo, you already have the docs and integration guides.
|
||||
Technical docs, integrations docs, and API docs are all in the same repository.
|
||||
|
||||
### Set up your local build tools
|
||||
|
||||
Run the following command to install or update the build tools for both the technical docs and integration guides.
|
||||
### Install toolchain
|
||||
|
||||
```shell
|
||||
make docs-install
|
||||
```
|
||||
|
||||
This command installs or updates the build dependencies such as Docusaurus, Prettier, and ESLint. You should run this command when you are first setting up your writing environment, and also if you encounter build check fails either when you build locally or when you push your PR to the authentik repository. Running this command will grab any new dependencies that we might have added to our build tool package.
|
||||
This installs and updates doc build dependencies (Docusaurus, ESLint, Prettier, and related packages).
|
||||
|
||||
:::tip
|
||||
If you have the [full development environment](../setup/full-dev-environment.mdx) installed you can run `make install` to get all of the latest build tools and dependencies, not just those for building documentation.
|
||||
:::info
|
||||
If you are using the full development environment, `make install` also installs all docs dependencies.
|
||||
:::
|
||||
|
||||
## Writing or modifying technical docs
|
||||
## Technical docs workflow (`website/docs`)
|
||||
|
||||
In addition to following the [Style Guide](./style-guide.mdx) please review the following guidelines about our technical documentation (https://docs.goauthentik.io/docs/):
|
||||
|
||||
- For new entries, make sure to add any new pages to the `/docs/sidebar.mjs` file.
|
||||
Otherwise, the new page will not appear in the table of contents to the left.
|
||||
|
||||
- Always be sure to run the `make docs` command on your local branch _before_ pushing the PR to the authentik repo. This command does important linting, and the build check in our repo will fail if the linting has not been done. In general, check on the health of your build before pushing to the authentik repo, and also check on the build status of your PR after you create it.
|
||||
|
||||
For our technical documentation (https://docs.goauthentik.io/docs/), the following commands are used:
|
||||
|
||||
### Build locally
|
||||
### Build and lint
|
||||
|
||||
```shell
|
||||
make docs
|
||||
```
|
||||
|
||||
This command is a combination of `make docs-lint-fix` and `make docs-build`. It is important to run this command before committing changes because linter errors will prevent the build checks from passing.
|
||||
This runs linting and build steps required by CI.
|
||||
|
||||
### Live editing
|
||||
### Live preview
|
||||
|
||||
```shell
|
||||
make docs-watch
|
||||
```
|
||||
|
||||
Starts a local development server for the documentation site and opens a preview in your browser. This command will automatically rebuild your local documentation site in real time, as you write or make changes to the Markdown files in the `website/docs` directory.
|
||||
This starts a local preview server and rebuilds when files change.
|
||||
|
||||
## Writing or modifying integration guides
|
||||
### Sidebar updates
|
||||
|
||||
In addition to following the [Style Guide](./style-guide.mdx) please review the following guidelines about our integration guides (https://integrations.goauthentik.io/).
|
||||
If you add a new technical docs page, update `website/docs/sidebar.mjs` unless the section is fully autogenerated.
|
||||
|
||||
- For new integration documentation, please use the Integrations template in our [GitHub repo](https://github.com/goauthentik/authentik) at `/website/integrations/template/service.md`.
|
||||
## Integration docs workflow (`website/integrations`)
|
||||
|
||||
- For placeholder domains, use `authentik.company` and `app-name.company`, where `app-name` is the name of the application that you are writing documentation for.
|
||||
Use the integration template at `website/integrations/template/service.md`.
|
||||
|
||||
- Make sure to create a directory for your service in a fitting category within [`/website/integrations/`](https://github.com/goauthentik/authentik/tree/main/website/integrations).
|
||||
|
||||
:::tip Sidebars and categories
|
||||
You no longer need to modify the integrations sidebar file manually. This is now automatically generated from the categories in [`/website/integrations/categories.mjs`](https://github.com/goauthentik/authentik/blob/main/website/integrations/categories.mjs).
|
||||
:::
|
||||
|
||||
When authoring integration guides, the following commands are used:
|
||||
|
||||
### Build locally
|
||||
### Build and lint
|
||||
|
||||
```shell
|
||||
make integrations
|
||||
```
|
||||
|
||||
This command is a combination of `make docs-lint-fix` and `make integrations-build`. This command should always be run on your local branch before committing your changes to a pull request to the authentik repo. It is important to run this command before committing changes because linter errors will prevent the build checks from passing.
|
||||
|
||||
### Live editing
|
||||
### Live preview
|
||||
|
||||
```shell
|
||||
make integrations-watch
|
||||
```
|
||||
|
||||
Starts a local development server for the integrations site and opens a preview in your browser. This command will automatically rebuild your local integrations site in real time, as you write or make changes to the Markdown files in the `website/integrations` directory.
|
||||
:::info Sidebars and categories
|
||||
Integrations sidebar structure is generated from `website/integrations/categories.mjs`. Keep categories aligned with your integration path.
|
||||
:::
|
||||
|
||||
## Templates
|
||||
|
||||
Use docs templates whenever possible:
|
||||
|
||||
- [Templates index](./templates/index.md).
|
||||
- [Tutorial template](./templates/tutorial-template.mdx).
|
||||
- [Integration template](https://integrations.goauthentik.io/applications#add-a-new-application).
|
||||
|
||||
## Developing the glossary
|
||||
|
||||
The [authentik glossary](/core/glossary/) provides definitions for both industry-standard terms (like LDAP, OAuth2, SAML) and authentik-specific concepts (like Flows, Stages, Blueprints).
|
||||
The [authentik glossary](/core/glossary/) includes both industry terms and authentik-specific concepts.
|
||||
|
||||
### Adding a new glossary term
|
||||
### Add a glossary term
|
||||
|
||||
1. Create a new `.mdx` file in `website/docs/core/glossary/terms/` (e.g., `my-term.mdx`).
|
||||
|
||||
2. Add frontmatter with the required metadata:
|
||||
1. Create a file in `website/docs/core/glossary/terms/`.
|
||||
2. Add frontmatter with glossary metadata.
|
||||
|
||||
```mdx
|
||||
---
|
||||
@@ -161,133 +140,160 @@ sidebar_custom_props:
|
||||
termName: My Term
|
||||
tags:
|
||||
- Category Name
|
||||
authentikSpecific: true # Only for authentik-specific terms
|
||||
authentikSpecific: true
|
||||
shortDescription: Brief one-line description.
|
||||
longDescription: Detailed explanation with context, use cases, and examples.
|
||||
---
|
||||
```
|
||||
|
||||
### Glossary metadata fields
|
||||
### Glossary metadata
|
||||
|
||||
- **`termName`** (required): The display name of the term
|
||||
- **`tags`** (required): Array of category tags for organizing terms. Common tags include:
|
||||
- Core Concepts
|
||||
- Flows
|
||||
- OAuth2/OIDC
|
||||
- SAML
|
||||
- Directory
|
||||
- Configuration
|
||||
- Protocols
|
||||
- **`authentikSpecific`** (optional): Set to `true` for authentik-specific terms. This displays an "authentik specific" badge next to the term name to distinguish it from industry-standard terminology. Omit this field for industry-standard terms.
|
||||
- **`shortDescription`** (required): Concise one-line summary displayed in the main glossary view
|
||||
- **`longDescription`** (optional): Detailed explanation shown when users expand the term
|
||||
- `termName` (required): Display name.
|
||||
- `tags` (required): Category tags.
|
||||
- `authentikSpecific` (optional): `true` for authentik-specific concepts.
|
||||
- `shortDescription` (required): One-line summary shown in list view.
|
||||
- `longDescription` (optional): Expanded detail shown on term expansion.
|
||||
|
||||
### Formatting guidelines
|
||||
## Developing the Learning Center
|
||||
|
||||
- Use backticks for inline code: \`application\`
|
||||
- Keep `shortDescription` to one sentence
|
||||
- In `longDescription`, you can use multiple paragraphs separated by blank lines
|
||||
The [Learning Center](/core/learning-center/) organizes learning resources through three discovery options on the landing page:
|
||||
|
||||
- Learning paths.
|
||||
- Filter articles.
|
||||
- Open full article index.
|
||||
|
||||
### Source of truth files
|
||||
|
||||
- Learning path definitions: `website/docusaurus-theme/components/LearningCenter/learningPathsConfig.ts`.
|
||||
- Learning Center category descriptions: `website/docusaurus-theme/theme/utils/learningCenter/categoryDescriptions.ts`.
|
||||
- Learning Center content root: `website/docs/core/learning-center/`.
|
||||
|
||||
### Add a learning resource
|
||||
|
||||
1. Create or choose a category directory under `website/docs/core/learning-center/`.
|
||||
2. Add a `.md` or `.mdx` resource file.
|
||||
3. Set `sidebar_custom_props` in frontmatter.
|
||||
|
||||
```mdx
|
||||
---
|
||||
title: My Tutorial Title
|
||||
sidebar_custom_props:
|
||||
resourceName: My Tutorial Title
|
||||
category: Getting Started
|
||||
learningPaths:
|
||||
- getting-started
|
||||
shortDescription: Brief one-line description shown on the card.
|
||||
longDescription: Optional detailed description.
|
||||
difficulty: beginner
|
||||
resourceType: tutorial
|
||||
estimatedTime: 15 min
|
||||
---
|
||||
```
|
||||
|
||||
### Learning Center resource metadata
|
||||
|
||||
- `resourceName` (recommended): Card title override.
|
||||
- `category` (recommended): Category label; should match parent `_category_.json` label.
|
||||
- `learningPaths` (recommended): Array of path tags from `learningPathsConfig.ts`.
|
||||
- `shortDescription` (recommended): Summary shown on cards.
|
||||
- `longDescription` (optional): Additional context.
|
||||
- `difficulty` (optional): `beginner`, `intermediate`, or `advanced`.
|
||||
- `resourceType` (optional): `tutorial`, `guide`, `reference`, `video`, or `example`.
|
||||
- `estimatedTime` (optional): Time estimate such as `15 min` or `1 hour`.
|
||||
|
||||
### Add a learning path
|
||||
|
||||
1. Add a new path object in `learningPathsConfig.ts` with `title`, `description`, `filterTag`, and `difficulty`.
|
||||
2. Add path container page: `website/docs/core/learning-center/path/<filterTag>.mdx`.
|
||||
3. Add the matching `filterTag` to each resource's `sidebar_custom_props.learningPaths`.
|
||||
|
||||
```mdx
|
||||
---
|
||||
title: My Learning Path
|
||||
slug: /core/learning-center/path/my-learning-path
|
||||
hide_table_of_contents: true
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import DocCardList from "@theme/DocCardList";
|
||||
|
||||
<DocCardList />
|
||||
```
|
||||
|
||||
### Container page rules
|
||||
|
||||
These pages are containers and should not include resource metadata:
|
||||
|
||||
- `website/docs/core/learning-center/index.mdx`.
|
||||
- `website/docs/core/learning-center/articles.mdx`.
|
||||
- Files in `website/docs/core/learning-center/path/`.
|
||||
|
||||
### Add a Learning Center category
|
||||
|
||||
1. Create a directory under `website/docs/core/learning-center/`.
|
||||
2. Add `_category_.json`.
|
||||
3. Register category description in `categoryDescriptions.ts`.
|
||||
|
||||
```json
|
||||
{
|
||||
"label": "My Category",
|
||||
"position": 4,
|
||||
"description": "A clear description of what this category covers and who it is for."
|
||||
}
|
||||
```
|
||||
|
||||
## Page routing and URLs
|
||||
|
||||
Every documentation page you see on our website starts as a simple Markdown file in our repository. When you create or edit these files, our build system automatically transforms them into web pages with predictable URLs.
|
||||
Docusaurus generates URLs from file paths.
|
||||
|
||||
### Converting file paths to URLs
|
||||
|
||||
Let's take a look at the file path of the [Style Guide page](https://docs.goauthentik.io/developer-docs/docs/style-guide/):
|
||||
Example file:
|
||||
|
||||
```text
|
||||
/website/docs/developer-docs/docs/style-guide.mdx
|
||||
```
|
||||
|
||||
Compared to the URL path of this page, there are a few differences:
|
||||
|
||||
- The `website/docs` prefix is dropped.
|
||||
- File extensions are removed.
|
||||
- A trailing slash is added.
|
||||
|
||||
This results in the following URL path:
|
||||
Resulting URL:
|
||||
|
||||
```text
|
||||
https://docs.goauthentik.io/developer-docs/docs/style-guide/
|
||||
```
|
||||
|
||||
The final published URL is made possible with a combination of [Docusaurus's routing system](https://docusaurus.io/docs/advanced/routing) and [Netlify's redirects](https://docs.netlify.com/routing/redirects/).
|
||||
## Sidebars
|
||||
|
||||
### Sidebar files
|
||||
Sidebars define navigation structure:
|
||||
|
||||
The sidebar files define the navigation structure of the documentation pages.
|
||||
- Docs: `website/docs/sidebar.mjs`.
|
||||
- Integrations: `website/integrations/sidebar.mjs`.
|
||||
- API: `website/api/sidebar.mjs`.
|
||||
|
||||
- **Documentation**: [`website/docs/sidebar.mjs`](https://github.com/goauthentik/authentik/blob/main/website/docs/sidebar.mjs)
|
||||
- **Integrations**: [`website/integrations/sidebar.mjs`](https://github.com/goauthentik/authentik/blob/main/website/integrations/sidebar.mjs)
|
||||
- Automatically generated from the categories in [`/website/integrations/categories.mjs`](https://github.com/goauthentik/authentik/blob/main/website/integrations/categories.mjs).
|
||||
- **API Reference**: [`website/api/sidebar.mjs`](https://github.com/goauthentik/authentik/blob/main/website/api/sidebar.mjs)
|
||||
- Mostly automatically generated from authentik API schema.
|
||||
## Redirects
|
||||
|
||||
### Redirects
|
||||
Redirect rules prevent broken links when pages move.
|
||||
|
||||
Sometimes we need to move pages or change URLs. Instead of breaking bookmarks and links, we can define a redirect to automatically send readers from old URLs to new ones.
|
||||
- Docs redirects: `website/docs/static/_redirects`.
|
||||
- Integrations redirects: `website/integrations/static/_redirects`.
|
||||
- API redirects: `website/api/static/_redirects`.
|
||||
|
||||
All our redirects are defined within three files:
|
||||
Redirect format:
|
||||
|
||||
- **Documentation**: [`website/docs/static/_redirects`](https://github.com/goauthentik/authentik/blob/main/website/docs/static/_redirects)
|
||||
- **Integrations**: [`website/integrations/static/_redirects`](https://github.com/goauthentik/authentik/blob/main/website/integrations/static/_redirects)
|
||||
- **API Reference**: [`website/api/static/_redirects`](https://github.com/goauthentik/authentik/blob/main/website/api/static/_redirects)
|
||||
|
||||
A `_redirects` file contains a list of rules that define how to handle requests, each of which has the following format:
|
||||
|
||||
1. The source URL path (i.e the old URL to match against).
|
||||
2. The destination URL path (i.e. the new URL to redirect to).
|
||||
3. The HTTP status code to use when redirecting, followed by an exclamation mark (`!`).
|
||||
|
||||
For example, if we moved our applications page:
|
||||
1. Source path.
|
||||
2. Destination path.
|
||||
3. HTTP status code with `!`.
|
||||
|
||||
```text title="website/docs/static/_redirects"
|
||||
# Source URL Path | Destination URL Path | Status Code
|
||||
/core/applications /add-secure-apps/applications/ 302!
|
||||
```
|
||||
|
||||
Anyone visiting the old URL will automatically land on the new page using a combination of Netlify and Docusaurus.
|
||||
### Update a URL safely
|
||||
|
||||
#### Initial page loads (server-side)
|
||||
|
||||
When a reader first visits a documentation page or refreshes their browser:
|
||||
|
||||
1. Their browser requests the URL from our server (Netlify).
|
||||
2. Netlify checks if that exact page exists.
|
||||
3. If not, it checks our `_redirects` file for a matching rule.
|
||||
4. The server sends back the correct page, or a 404 if no matching rule exists.
|
||||
|
||||
#### Navigating between pages (client-side)
|
||||
|
||||
When a reader clicks a link to another documentation page:
|
||||
|
||||
1. Docusaurus intercepts the click (no server request needed).
|
||||
2. The URL in the browser's address bar changes.
|
||||
3. Docusaurus router fetches the new page content without a full reload.
|
||||
|
||||
If Docusaurus's router attempts to render a page that does not exist, the `_redirects` file will be used to determine if a redirect rule should be applied, without a server request or a full reload.
|
||||
|
||||
Whether the reader is viewing a page for the first time or navigating between pages, this arrangement allows us to have a single source of truth for all URLs, ensuring that each page remains consistently accessible across authentik versions and throughout our three Docusaurus deployments (Topics, Integrations, and API).
|
||||
|
||||
### Updating a page's URL
|
||||
|
||||
:::danger[Every URL is a promise]
|
||||
|
||||
When someone bookmarks a page or shares a link, they expect it to keep working.
|
||||
|
||||
**Before changing any URL, ask yourself:**
|
||||
|
||||
- [x] Is this move absolutely necessary?
|
||||
- [x] Could better organization be achieved without moving files?
|
||||
- [x] Will this help or confuse readers migrating between authentik versions?
|
||||
|
||||
Remember, [Cool URIs don't change!](https://www.w3.org/Provider/Style/URI)
|
||||
:::danger
|
||||
Every URL is a promise. Before moving a page, confirm the move is necessary and add a redirect rule.
|
||||
:::
|
||||
|
||||
Moving a documentation page to a new location requires updating a `sidebar.mjs` and `_redirects` file.
|
||||
When moving a page:
|
||||
|
||||
1. Take note of the page's current URL path in the browser's address bar.
|
||||
2. Move the Markdown file to the new location.
|
||||
3. Add a new redirect rule to the `_redirects` file in the respective [documentation directory](#redirects).
|
||||
4. Update the `sidebar.mjs` file in the respective [documentation directory](#redirects).
|
||||
1. Record current URL.
|
||||
2. Move the file.
|
||||
3. Add redirect rule.
|
||||
4. Update the relevant sidebar.
|
||||
5. Validate locally and in preview.
|
||||
|
||||
93
website/docs/developer-docs/docs/writing-tutorials.md
Normal file
93
website/docs/developer-docs/docs/writing-tutorials.md
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Writing tutorials
|
||||
---
|
||||
|
||||
# Writing tutorials for authentik
|
||||
|
||||
Tutorials teach by doing. A tutorial should guide a reader through one concrete task, with a clear starting point and a verifiable end state.
|
||||
|
||||
## What a tutorial is
|
||||
|
||||
A strong tutorial is:
|
||||
|
||||
1. **Goal-oriented**: It starts with a clear outcome.
|
||||
2. **Task-driven**: It focuses on one practical task.
|
||||
3. **Sequential**: Steps are ordered and easy to follow.
|
||||
4. **Runnable**: Instructions are specific enough to execute without guesswork.
|
||||
5. **Verifiable**: It shows how to confirm success.
|
||||
|
||||
## Tutorial vs. other doc types
|
||||
|
||||
| Type | Primary purpose | Reader behavior |
|
||||
| ---------- | ------------------------------------ | ------------------------------------- |
|
||||
| Tutorial | Learn by completing one task | Follows steps in order |
|
||||
| Procedural | Complete an operational task quickly | Skims to relevant step |
|
||||
| Conceptual | Understand why and when to use | Reads for context and decision-making |
|
||||
| Reference | Look up exact values/syntax | Jumps directly to specific details |
|
||||
|
||||
## Choose the right topic
|
||||
|
||||
Pick topics that benefit from guided execution:
|
||||
|
||||
- Multi-step workflows.
|
||||
- Error-prone or easy-to-misconfigure setups.
|
||||
- Frequent onboarding tasks.
|
||||
- Integrations where verification is important.
|
||||
|
||||
Avoid tutorial format for pure background material or lookup tables.
|
||||
|
||||
## Use the template
|
||||
|
||||
Start with the [tutorial template](./templates/tutorial-template.mdx). The template is frontmatter-first and already includes:
|
||||
|
||||
- Learning Center metadata placeholders.
|
||||
- A recommended section order.
|
||||
- Admonition usage patterns.
|
||||
- Verification and troubleshooting structure.
|
||||
|
||||
## Writing standards
|
||||
|
||||
Follow the [Style Guide](./style-guide.mdx), then apply these tutorial-specific rules.
|
||||
|
||||
### Write for execution
|
||||
|
||||
- Start each step with an action verb.
|
||||
- Name exact UI elements, paths, commands, and values.
|
||||
- Keep each step focused on one action.
|
||||
|
||||
### Explain why only when it helps completion
|
||||
|
||||
- Add short context when it prevents mistakes.
|
||||
- Move deep conceptual explanations to a separate conceptual page.
|
||||
|
||||
### Keep admonitions intentional
|
||||
|
||||
- Use `:::info` for optional context.
|
||||
- Use `:::warning` for risky actions.
|
||||
- Use `:::danger` only for irreversible impact.
|
||||
|
||||
### Keep code examples runnable
|
||||
|
||||
- Prefer minimal, complete snippets.
|
||||
- Avoid pseudo-config unless clearly labeled.
|
||||
- Include only fields relevant to the task.
|
||||
|
||||
## Quality checklist before opening a PR
|
||||
|
||||
1. Follow the tutorial from a clean baseline.
|
||||
2. Confirm every command and path still works.
|
||||
3. Verify the expected result section is accurate.
|
||||
4. Validate links and cross-references.
|
||||
5. Run local checks from [Writing documentation](./writing-documentation.md).
|
||||
|
||||
## Publishing in the Learning Center
|
||||
|
||||
If the tutorial is part of the Learning Center, populate `sidebar_custom_props` in frontmatter using the fields from the template.
|
||||
|
||||
Canonical Learning Center authoring guidance lives in [Writing documentation: Developing the Learning Center](./writing-documentation.md#developing-the-learning-center).
|
||||
|
||||
## Submission and maintenance
|
||||
|
||||
1. Submit the PR with the tutorial and required sidebar updates.
|
||||
2. Address reviewer feedback, especially around clarity and testability.
|
||||
3. Revisit tutorials when UI, feature behavior, or recommended patterns change.
|
||||
@@ -21,11 +21,7 @@ const releaseEnvironment = prepareReleaseEnvironment();
|
||||
/**
|
||||
* @type {SidebarItemConfig[]}
|
||||
*/
|
||||
const items = [
|
||||
{
|
||||
type: "doc",
|
||||
id: "index",
|
||||
},
|
||||
const documentationItems = [
|
||||
{
|
||||
//#region Core Concepts
|
||||
type: "category",
|
||||
@@ -42,24 +38,6 @@ const items = [
|
||||
{
|
||||
//#endregion
|
||||
|
||||
//#region Enterprise
|
||||
type: "category",
|
||||
label: "Enterprise",
|
||||
collapsed: true,
|
||||
link: {
|
||||
type: "doc",
|
||||
id: "enterprise/index",
|
||||
},
|
||||
items: [
|
||||
"enterprise/get-started",
|
||||
"enterprise/enterprise-features",
|
||||
"enterprise/manage-enterprise",
|
||||
"enterprise/enterprise-support",
|
||||
],
|
||||
},
|
||||
{
|
||||
//#endregion
|
||||
|
||||
//#region Installation and Configuration
|
||||
type: "category",
|
||||
label: "Installation and Configuration ",
|
||||
@@ -851,82 +829,6 @@ const items = [
|
||||
{
|
||||
//#endregion
|
||||
|
||||
//#region Developer Documentation
|
||||
type: "category",
|
||||
label: "Developer Documentation",
|
||||
collapsed: true,
|
||||
link: {
|
||||
type: "doc",
|
||||
id: "developer-docs/index",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: "link",
|
||||
href: releaseEnvironment.apiReferenceOrigin,
|
||||
label: "API Overview",
|
||||
className: "api-overview",
|
||||
},
|
||||
{
|
||||
type: "doc",
|
||||
id: "developer-docs/contributing",
|
||||
label: "Contributing",
|
||||
},
|
||||
|
||||
{
|
||||
//#endregion
|
||||
|
||||
//#region Development environment
|
||||
type: "category",
|
||||
label: "Development environment",
|
||||
link: {
|
||||
type: "doc",
|
||||
id: "developer-docs/setup/index",
|
||||
},
|
||||
items: [
|
||||
"developer-docs/setup/full-dev-environment",
|
||||
"developer-docs/setup/frontend-dev-environment",
|
||||
"developer-docs/setup/debugging",
|
||||
],
|
||||
},
|
||||
{
|
||||
//#endregion
|
||||
|
||||
//#region Writing documentation
|
||||
type: "category",
|
||||
label: "Writing documentation",
|
||||
link: {
|
||||
type: "doc",
|
||||
id: "developer-docs/docs/writing-documentation",
|
||||
},
|
||||
items: [
|
||||
"developer-docs/docs/style-guide",
|
||||
"developer-docs/docs/theming/index",
|
||||
{
|
||||
type: "category",
|
||||
label: "Templates",
|
||||
link: {
|
||||
type: "doc",
|
||||
id: "developer-docs/docs/templates/index",
|
||||
},
|
||||
items: [
|
||||
"developer-docs/docs/templates/combo",
|
||||
"developer-docs/docs/templates/procedural",
|
||||
"developer-docs/docs/templates/conceptual",
|
||||
"developer-docs/docs/templates/reference",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "doc",
|
||||
id: "developer-docs/releases/index",
|
||||
},
|
||||
"developer-docs/translation",
|
||||
],
|
||||
},
|
||||
{
|
||||
//#endregion
|
||||
|
||||
//#region Security
|
||||
type: "category",
|
||||
label: "Security",
|
||||
@@ -1008,6 +910,130 @@ const items = [
|
||||
"troubleshooting/forward_auth",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @type {SidebarItemConfig[]}
|
||||
*/
|
||||
const items = [
|
||||
{
|
||||
type: "doc",
|
||||
id: "index",
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Documentation",
|
||||
collapsed: true,
|
||||
items: documentationItems,
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Learning Center",
|
||||
collapsed: true,
|
||||
link: {
|
||||
type: "doc",
|
||||
id: "core/learning-center/index",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: "autogenerated",
|
||||
dirName: "core/learning-center",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
//#region Enterprise
|
||||
type: "category",
|
||||
label: "Enterprise",
|
||||
collapsed: true,
|
||||
link: {
|
||||
type: "doc",
|
||||
id: "enterprise/index",
|
||||
},
|
||||
items: [
|
||||
"enterprise/get-started",
|
||||
"enterprise/enterprise-features",
|
||||
"enterprise/manage-enterprise",
|
||||
"enterprise/enterprise-support",
|
||||
],
|
||||
},
|
||||
{
|
||||
//#endregion
|
||||
|
||||
//#region Developer Documentation
|
||||
type: "category",
|
||||
label: "Developer",
|
||||
collapsed: true,
|
||||
link: {
|
||||
type: "doc",
|
||||
id: "developer-docs/index",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: "link",
|
||||
href: releaseEnvironment.apiReferenceOrigin,
|
||||
label: "API Overview",
|
||||
className: "api-overview",
|
||||
},
|
||||
{
|
||||
type: "doc",
|
||||
id: "developer-docs/contributing",
|
||||
label: "Contributing",
|
||||
},
|
||||
{
|
||||
//#endregion
|
||||
|
||||
//#region Development environment
|
||||
type: "category",
|
||||
label: "Development environment",
|
||||
link: {
|
||||
type: "doc",
|
||||
id: "developer-docs/setup/index",
|
||||
},
|
||||
items: [
|
||||
"developer-docs/setup/full-dev-environment",
|
||||
"developer-docs/setup/frontend-dev-environment",
|
||||
"developer-docs/setup/debugging",
|
||||
],
|
||||
},
|
||||
{
|
||||
//#endregion
|
||||
|
||||
//#region Writing documentation
|
||||
type: "category",
|
||||
label: "Writing documentation",
|
||||
link: {
|
||||
type: "doc",
|
||||
id: "developer-docs/docs/writing-documentation",
|
||||
},
|
||||
items: [
|
||||
"developer-docs/docs/style-guide",
|
||||
"developer-docs/docs/theming/index",
|
||||
"developer-docs/docs/writing-tutorials",
|
||||
{
|
||||
type: "category",
|
||||
label: "Templates",
|
||||
link: {
|
||||
type: "doc",
|
||||
id: "developer-docs/docs/templates/index",
|
||||
},
|
||||
items: [
|
||||
"developer-docs/docs/templates/combo",
|
||||
"developer-docs/docs/templates/procedural",
|
||||
"developer-docs/docs/templates/conceptual",
|
||||
"developer-docs/docs/templates/reference",
|
||||
"developer-docs/docs/templates/tutorial-template",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "doc",
|
||||
id: "developer-docs/releases/index",
|
||||
},
|
||||
"developer-docs/translation",
|
||||
],
|
||||
},
|
||||
{
|
||||
//#endregion
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import styles from "./styling/filters.module.css";
|
||||
import { formatCategory } from "./utils";
|
||||
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Category-based navigation for filtering resources.
|
||||
* Shows all available categories and highlights selected ones.
|
||||
*/
|
||||
export interface CategoryNavProps {
|
||||
availableCategories: string[];
|
||||
selectedCategories: string[];
|
||||
onToggleCategory: (category: string) => void;
|
||||
}
|
||||
|
||||
export const CategoryNav: React.FC<CategoryNavProps> = ({
|
||||
availableCategories,
|
||||
selectedCategories,
|
||||
onToggleCategory,
|
||||
}) => {
|
||||
if (availableCategories.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.categoryNav}>
|
||||
<span className={styles.navLabel}>Categories:</span>
|
||||
{availableCategories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
className={clsx(
|
||||
styles.categoryButton,
|
||||
selectedCategories.includes(category) && styles.active,
|
||||
)}
|
||||
onClick={() => onToggleCategory(category)}
|
||||
aria-pressed={selectedCategories.includes(category)}
|
||||
>
|
||||
{formatCategory(category)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryNav;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { type DifficultyLevel, getDifficultyLabel } from "../../theme/utils/learningCenter/utils";
|
||||
import styles from "./styling/filters.module.css";
|
||||
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Difficulty level filter for resources.
|
||||
*/
|
||||
export interface DifficultyFilterProps {
|
||||
availableDifficulties: DifficultyLevel[];
|
||||
selectedDifficulty: DifficultyLevel | null;
|
||||
onSelectDifficulty: (difficulty: DifficultyLevel | null) => void;
|
||||
}
|
||||
|
||||
export const DifficultyFilter: React.FC<DifficultyFilterProps> = ({
|
||||
availableDifficulties,
|
||||
selectedDifficulty,
|
||||
onSelectDifficulty,
|
||||
}) => {
|
||||
if (availableDifficulties.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.difficultyFilter}>
|
||||
<span className={styles.navLabel}>Experience Level:</span>
|
||||
<button
|
||||
className={clsx(
|
||||
styles.difficultyButton,
|
||||
selectedDifficulty === null && styles.active,
|
||||
)}
|
||||
onClick={() => onSelectDifficulty(null)}
|
||||
aria-pressed={selectedDifficulty === null}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{availableDifficulties.map((difficulty) => (
|
||||
<button
|
||||
key={difficulty}
|
||||
className={clsx(
|
||||
styles.difficultyButton,
|
||||
styles[`difficulty-${difficulty}`],
|
||||
selectedDifficulty === difficulty && styles.active,
|
||||
)}
|
||||
onClick={() => onSelectDifficulty(difficulty)}
|
||||
aria-pressed={selectedDifficulty === difficulty}
|
||||
>
|
||||
{getDifficultyLabel(difficulty)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DifficultyFilter;
|
||||
@@ -0,0 +1,38 @@
|
||||
import styles from "./styling/filters.module.css";
|
||||
|
||||
import React from "react";
|
||||
|
||||
/** Controlled filter input with a clear button. */
|
||||
export interface FilterInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onClear: () => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const FilterInput: React.FC<FilterInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
placeholder = "Search articles...",
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.filter}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
className={styles.filterInput}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label="Filter learning resources"
|
||||
/>
|
||||
{value ? (
|
||||
<button className={styles.clearButton} onClick={onClear} aria-label="Clear filter">
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterInput;
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
getDifficultyLabel,
|
||||
type LearningCenterResource,
|
||||
} from "../../theme/utils/learningCenter/utils";
|
||||
import type { LearningPathDef } from "./learningPathsConfig";
|
||||
import styles from "./styling/learningPaths.module.css";
|
||||
|
||||
import Link from "@docusaurus/Link";
|
||||
import clsx from "clsx";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
export interface LearningPathsProps {
|
||||
/** Learning path definitions */
|
||||
paths: LearningPathDef[];
|
||||
/** All available resources to calculate article counts */
|
||||
resources: LearningCenterResource[];
|
||||
/** Optional title override for this section */
|
||||
title?: string;
|
||||
/** Hide section title when wrapped by another heading */
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the featured learning paths section at the top of the Learning Center.
|
||||
* Shows curated paths with difficulty levels and dynamically calculated article counts.
|
||||
* Clicking a path opens a dedicated page for that learning track.
|
||||
*/
|
||||
export const LearningPaths: React.FC<LearningPathsProps> = ({
|
||||
paths,
|
||||
resources,
|
||||
title = "Learning paths",
|
||||
hideTitle = false,
|
||||
}) => {
|
||||
const articleCountsByPath = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
resources.forEach((resource) => {
|
||||
resource.learningPaths.forEach((tag) => {
|
||||
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||
});
|
||||
});
|
||||
return counts;
|
||||
}, [resources]);
|
||||
|
||||
if (paths.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.learningPathsSection}>
|
||||
{!hideTitle ? (
|
||||
<div className={styles.learningPathsHeader}>
|
||||
<h2 className={styles.learningPathsTitle}>{title}</h2>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.learningPathsList} role="list" aria-label="Learning paths">
|
||||
{paths.map((path) => {
|
||||
const articleCount = articleCountsByPath.get(path.filterTag) ?? 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={path.filterTag}
|
||||
to={`/core/learning-center/path/${path.filterTag}/`}
|
||||
className={styles.learningPathCardLink}
|
||||
>
|
||||
<article className={styles.learningPathCard} role="listitem">
|
||||
<div className={styles.learningPathCardMain}>
|
||||
<h3 className={styles.learningPathCardTitle}>{path.title}</h3>
|
||||
</div>
|
||||
<div className={styles.learningPathCardMetaColumn}>
|
||||
<span
|
||||
className={clsx(
|
||||
styles.learningPathLevelBadge,
|
||||
styles[`learningPathLevelBadge-${path.difficulty}`],
|
||||
)}
|
||||
>
|
||||
{getDifficultyLabel(path.difficulty)}
|
||||
</span>
|
||||
<span className={styles.learningPathArticleCount}>
|
||||
{articleCount} article{articleCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LearningPaths;
|
||||
14
website/docusaurus-theme/components/LearningCenter/index.ts
Normal file
14
website/docusaurus-theme/components/LearningCenter/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export { CategoryNav } from "./CategoryNav";
|
||||
export type { CategoryNavProps } from "./CategoryNav";
|
||||
|
||||
export { DifficultyFilter } from "./DifficultyFilter";
|
||||
export type { DifficultyFilterProps } from "./DifficultyFilter";
|
||||
|
||||
export { FilterInput } from "./FilterInput";
|
||||
export type { FilterInputProps } from "./FilterInput";
|
||||
|
||||
export { LearningPaths } from "./LearningPaths";
|
||||
export type { LearningPathsProps } from "./LearningPaths";
|
||||
export type { LearningPathDef } from "./learningPathsConfig";
|
||||
|
||||
export * from "./utils";
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { DifficultyLevel } from "../../theme/utils/learningCenter/utils";
|
||||
|
||||
export interface LearningPathDef {
|
||||
title: string;
|
||||
description: string;
|
||||
filterTag: string;
|
||||
difficulty: DifficultyLevel;
|
||||
}
|
||||
|
||||
export const LEARNING_PATHS: LearningPathDef[] = [
|
||||
{
|
||||
title: "Getting Started with authentik",
|
||||
description: "Install, configure, and roll out your first production-ready flows.",
|
||||
filterTag: "getting-started",
|
||||
difficulty: "beginner",
|
||||
},
|
||||
{
|
||||
title: "Managing Users and Sources",
|
||||
description: "Connect identity sources and design a clean user lifecycle model.",
|
||||
filterTag: "users-sources",
|
||||
difficulty: "intermediate",
|
||||
},
|
||||
{
|
||||
title: "Security Best Practices",
|
||||
description: "Harden policies, enforce MFA, and reduce risky authentication paths.",
|
||||
filterTag: "security",
|
||||
difficulty: "advanced",
|
||||
},
|
||||
{
|
||||
title: "Providers and Protocols",
|
||||
description: "Master OAuth2/OIDC, SAML, and provider architecture decisions.",
|
||||
filterTag: "providers-protocols",
|
||||
difficulty: "advanced",
|
||||
},
|
||||
{
|
||||
title: "Fundamentals of authentik flows",
|
||||
description: "Learn how flows, stages, and policies connect across core user journeys.",
|
||||
filterTag: "fundamentals-flows",
|
||||
difficulty: "beginner",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,91 @@
|
||||
/* Learning Center shared variables */
|
||||
:global(:root) {
|
||||
--lc-category-accent: var(--ifm-color-primary);
|
||||
--lc-difficulty-beginner: #22c55e;
|
||||
--lc-difficulty-intermediate: #3b82f6;
|
||||
--lc-difficulty-advanced: #8b5cf6;
|
||||
}
|
||||
|
||||
.learningCenter {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
--lc-type-display: clamp(1.3rem, 1.8vw, 1.5rem);
|
||||
--lc-type-section-title: 1.16rem;
|
||||
--lc-type-item-title: clamp(1.03rem, 1.2vw, 1.12rem);
|
||||
--lc-type-body: 0.95rem;
|
||||
}
|
||||
|
||||
.resourceList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 0;
|
||||
color: var(--ifm-color-primary);
|
||||
border-bottom: 2px solid var(--ifm-color-emphasis-300);
|
||||
position: sticky;
|
||||
top: var(--ifm-navbar-height, 60px);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
html[data-theme="light"] .sectionTitle {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .sectionTitle {
|
||||
background-color: #1b1b1d;
|
||||
}
|
||||
|
||||
.resourceGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.categoryDescriptions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.categoryDescriptionText {
|
||||
margin: 0;
|
||||
padding: 1rem 1.25rem;
|
||||
background-color: var(--ifm-color-emphasis-100);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--lc-category-accent);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
|
||||
.noResults {
|
||||
padding: 2.5rem 2rem;
|
||||
text-align: center;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
border: 1px dashed var(--ifm-color-emphasis-300);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.noResults p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.resourceGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
.filterModeSection {
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 12px;
|
||||
padding: 0.8rem 1rem 0.3rem;
|
||||
margin-bottom: 0.35rem;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
}
|
||||
|
||||
.filter {
|
||||
margin-bottom: 1.25rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
padding: 0.58rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--ifm-color-primary);
|
||||
background: var(--ifm-color-primary);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.searchButton:hover {
|
||||
background: var(--ifm-color-primary-dark);
|
||||
border-color: var(--ifm-color-primary-dark);
|
||||
}
|
||||
|
||||
.filterInput {
|
||||
flex: 1;
|
||||
min-width: 280px;
|
||||
max-width: 620px;
|
||||
padding: 0.65rem 0.9rem;
|
||||
font-size: 1rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 6px;
|
||||
background-color: var(--ifm-background-color);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.filterInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--ifm-color-primary);
|
||||
box-shadow: 0 0 0 3px var(--ifm-color-primary-lightest);
|
||||
}
|
||||
|
||||
.filterInput::placeholder {
|
||||
color: var(--ifm-color-emphasis-500);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
background-color: transparent;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clearButton:hover {
|
||||
background-color: var(--ifm-color-danger-contrast-background);
|
||||
border-color: var(--ifm-color-danger);
|
||||
color: var(--ifm-color-danger-dark);
|
||||
}
|
||||
|
||||
.categoryNav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.quickBrowseRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.85rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.quickBrowseRow:last-child {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.navLabel {
|
||||
font-weight: 700;
|
||||
margin-right: 0.5rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
|
||||
.categoryButton {
|
||||
padding: 0.4rem 0.875rem;
|
||||
border-radius: 6px;
|
||||
background-color: transparent;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.categoryButton:hover {
|
||||
background-color: var(--ifm-color-emphasis-100);
|
||||
border-color: var(--ifm-color-emphasis-400);
|
||||
color: var(--ifm-color-emphasis-900);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.categoryButton.active {
|
||||
background-color: var(--ifm-color-primary);
|
||||
border-color: var(--ifm-color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.categoryButton.active:hover {
|
||||
background-color: var(--ifm-color-primary-dark);
|
||||
border-color: var(--ifm-color-primary-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.difficultyFilter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.difficultyButton {
|
||||
padding: 0.4rem 0.875rem;
|
||||
border-radius: 6px;
|
||||
background-color: transparent;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.difficultyButton:hover {
|
||||
background-color: var(--ifm-color-emphasis-100);
|
||||
border-color: var(--ifm-color-emphasis-400);
|
||||
}
|
||||
|
||||
.difficultyButton.active {
|
||||
background-color: var(--ifm-color-primary);
|
||||
border-color: var(--ifm-color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.difficultyButton.active:hover {
|
||||
background-color: var(--ifm-color-primary-dark);
|
||||
border-color: var(--ifm-color-primary-dark);
|
||||
}
|
||||
|
||||
.difficulty-beginner {
|
||||
border-color: var(--lc-difficulty-beginner);
|
||||
}
|
||||
|
||||
.difficulty-beginner.active {
|
||||
background-color: var(--lc-difficulty-beginner);
|
||||
border-color: var(--lc-difficulty-beginner);
|
||||
}
|
||||
|
||||
.difficulty-intermediate {
|
||||
border-color: var(--lc-difficulty-intermediate);
|
||||
}
|
||||
|
||||
.difficulty-intermediate.active {
|
||||
background-color: var(--lc-difficulty-intermediate);
|
||||
border-color: var(--lc-difficulty-intermediate);
|
||||
}
|
||||
|
||||
.difficulty-advanced {
|
||||
border-color: var(--lc-difficulty-advanced);
|
||||
}
|
||||
|
||||
.difficulty-advanced.active {
|
||||
background-color: var(--lc-difficulty-advanced);
|
||||
border-color: var(--lc-difficulty-advanced);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filterInput {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.categoryNav {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.quickBrowseRow {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.difficultyFilter {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
.discoveryIntro {
|
||||
margin: 0 0 1.2rem;
|
||||
}
|
||||
|
||||
.discoveryTitle {
|
||||
margin: 0;
|
||||
font-size: var(--lc-type-display);
|
||||
line-height: 1.25;
|
||||
color: var(--ifm-heading-color);
|
||||
}
|
||||
|
||||
.discoveryDescription {
|
||||
margin: 0.45rem 0 0;
|
||||
font-size: var(--lc-type-body);
|
||||
line-height: 1.55;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
|
||||
.discoveryKicker {
|
||||
margin: 0 0 0.35rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.discoveryPrimary {
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 14px;
|
||||
padding: 0.85rem 1rem 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
background: linear-gradient(
|
||||
170deg,
|
||||
var(--ifm-color-emphasis-100) 0%,
|
||||
var(--ifm-background-color) 65%
|
||||
);
|
||||
}
|
||||
|
||||
.discoveryAlternatives {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.discoveryOptionCard {
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
}
|
||||
|
||||
.discoveryOptionTitle {
|
||||
margin: 0 0 0.4rem;
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
font-size: 1.02rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.discoverySubTitle {
|
||||
margin: 0;
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
font-size: var(--lc-type-section-title);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.discoveryMethodBody {
|
||||
margin: 0 0 0.45rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: var(--lc-type-body);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.discoveryInlineCta {
|
||||
margin: 0.55rem 0 0;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.allArticlesLinkInline {
|
||||
font-weight: 700;
|
||||
color: var(--ifm-color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.allArticlesLinkInline:hover,
|
||||
.allArticlesLinkInline:focus-visible {
|
||||
color: var(--ifm-color-primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
.learningPathsSection {
|
||||
margin-bottom: 0.65rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.learningPathsHeader {
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.learningPathsTitle {
|
||||
font-size: var(--lc-type-section-title);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--ifm-heading-color);
|
||||
}
|
||||
|
||||
.learningPathsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.learningPathCardLink {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.learningPathCardLink:hover,
|
||||
.learningPathCardLink:focus-visible {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.learningPathCard {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 0.7rem;
|
||||
padding: 0.7rem 0.45rem 0.75rem;
|
||||
background: transparent;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
padding-left 0.2s ease;
|
||||
}
|
||||
|
||||
.learningPathCardLink:hover .learningPathCard {
|
||||
background-color: var(--ifm-color-emphasis-100);
|
||||
padding-left: 0.6rem;
|
||||
}
|
||||
|
||||
.learningPathCardLink:focus-visible .learningPathCard {
|
||||
outline: 2px solid var(--ifm-color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.learningPathCardMain {
|
||||
min-width: 0;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.learningPathCardTitle {
|
||||
margin: 0;
|
||||
font-size: var(--lc-type-item-title);
|
||||
line-height: 1.3;
|
||||
color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.learningPathCardLink:hover .learningPathCardTitle,
|
||||
.learningPathCardLink:focus-visible .learningPathCardTitle {
|
||||
color: var(--ifm-color-primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.learningPathCardMetaColumn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-direction: row;
|
||||
justify-self: end;
|
||||
white-space: nowrap;
|
||||
margin-top: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.learningPathLevelBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--ifm-color-emphasis-400);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
background: var(--ifm-background-color);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.learningPathLevelBadge-beginner {
|
||||
color: var(--lc-difficulty-beginner);
|
||||
border-color: color-mix(in srgb, var(--lc-difficulty-beginner) 45%, transparent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.learningPathLevelBadge-intermediate {
|
||||
color: var(--lc-difficulty-intermediate);
|
||||
border-color: color-mix(in srgb, var(--lc-difficulty-intermediate) 45%, transparent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.learningPathLevelBadge-advanced {
|
||||
color: var(--lc-difficulty-advanced);
|
||||
border-color: color-mix(in srgb, var(--lc-difficulty-advanced) 45%, transparent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.learningPathArticleCount {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.52rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.learningPathCard {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.learningPathCardMetaColumn {
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
.learningPathExperience {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.pathBackLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.9rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.pathBackLink:hover {
|
||||
color: var(--ifm-color-primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pathHero {
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 16px;
|
||||
padding: 1.4rem;
|
||||
margin-bottom: 1.2rem;
|
||||
background: linear-gradient(
|
||||
170deg,
|
||||
var(--ifm-color-emphasis-100) 0%,
|
||||
var(--ifm-background-color) 70%
|
||||
);
|
||||
}
|
||||
|
||||
.pathHeroEyebrow {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.pathHeroTitle {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: clamp(1.7rem, 3.2vw, 2.2rem);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.pathHeroDescription {
|
||||
margin: 0.75rem 0 0;
|
||||
max-width: 72ch;
|
||||
font-size: 1rem;
|
||||
line-height: 1.65;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
|
||||
.pathHeroMeta {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pathHeroMetaItem {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
background: var(--ifm-background-color);
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pathHeroMetaItemDifficulty-beginner {
|
||||
color: var(--lc-difficulty-beginner);
|
||||
border-color: color-mix(in srgb, var(--lc-difficulty-beginner) 45%, transparent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.pathHeroMetaItemDifficulty-intermediate {
|
||||
color: var(--lc-difficulty-intermediate);
|
||||
border-color: color-mix(in srgb, var(--lc-difficulty-intermediate) 45%, transparent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.pathHeroMetaItemDifficulty-advanced {
|
||||
color: var(--lc-difficulty-advanced);
|
||||
border-color: color-mix(in srgb, var(--lc-difficulty-advanced) 45%, transparent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.pathCurriculumSection {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.pathCurriculumTitle {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--ifm-heading-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pathHero {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
.resourceCardLink {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.resourceCardLink:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.resourceCard {
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--ifm-color-emphasis-200);
|
||||
background-color: var(--ifm-background-color);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 5%);
|
||||
transition:
|
||||
box-shadow 0.3s ease,
|
||||
transform 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
padding: 1.5rem 1.5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.resourceCardLink:hover .resourceCard {
|
||||
border-color: var(--ifm-color-primary);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.pathStepBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.18rem 0.58rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.resourceCardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.resourceTitle {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--ifm-heading-color);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.resourceMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.difficultyBadge {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.difficultyBadge.beginner {
|
||||
background-color: var(--lc-difficulty-beginner);
|
||||
}
|
||||
|
||||
.difficultyBadge.intermediate {
|
||||
background-color: var(--lc-difficulty-intermediate);
|
||||
}
|
||||
|
||||
.difficultyBadge.advanced {
|
||||
background-color: var(--lc-difficulty-advanced);
|
||||
}
|
||||
|
||||
.timeBadge {
|
||||
background-color: var(--ifm-color-emphasis-100);
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
|
||||
.timeBadge svg {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.resourceShort {
|
||||
font-size: 0.95rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 1rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.searchHighlight {
|
||||
background-color: #ffd54f;
|
||||
padding: 0.1rem 0.2rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .searchHighlight {
|
||||
background-color: #7c5c00;
|
||||
}
|
||||
33
website/docusaurus-theme/components/LearningCenter/utils.ts
Normal file
33
website/docusaurus-theme/components/LearningCenter/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/** Humanize a category by replacing separators and capitalizing words. */
|
||||
export function formatCategory(category: string): string {
|
||||
return category.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Tuple entries of group key and items. */
|
||||
export type Grouped<T> = ReadonlyArray<readonly [string, T[]]>;
|
||||
|
||||
/** Group resources by category. */
|
||||
export function groupByCategory<T extends { category: string }>(
|
||||
items: ReadonlyArray<T>,
|
||||
): Grouped<T> {
|
||||
const groups: Record<string, T[]> = {};
|
||||
items.forEach((item) => {
|
||||
const category = item.category || "General";
|
||||
if (!groups[category]) groups[category] = [];
|
||||
groups[category].push(item);
|
||||
});
|
||||
return Object.entries(groups).sort(([a], [b]) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/** Group resources by the first letter of their `resourceName` property. */
|
||||
export function groupByFirstLetter<T extends { resourceName: string }>(
|
||||
items: ReadonlyArray<T>,
|
||||
): Grouped<T> {
|
||||
const groups: Record<string, T[]> = {};
|
||||
items.forEach((item) => {
|
||||
const first = item.resourceName.charAt(0).toUpperCase();
|
||||
if (!groups[first]) groups[first] = [];
|
||||
groups[first].push(item);
|
||||
});
|
||||
return Object.entries(groups).sort(([a], [b]) => a.localeCompare(b));
|
||||
}
|
||||
@@ -35,12 +35,19 @@ export const DocusaurusExcludePatterns = [
|
||||
|
||||
//#region Preset
|
||||
|
||||
const googleAnalyticsPresetOptions =
|
||||
process.env.NODE_ENV === "production"
|
||||
? {
|
||||
googleAnalytics: {
|
||||
trackingID: "G-9MVR9WZFZH",
|
||||
anonymizeIP: true,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
/** @type {PresetOptions} */
|
||||
const CommonPresetOptions = {
|
||||
googleAnalytics: {
|
||||
trackingID: "G-9MVR9WZFZH",
|
||||
anonymizeIP: true,
|
||||
},
|
||||
...googleAnalyticsPresetOptions,
|
||||
theme: {
|
||||
customCss: [require.resolve("@goauthentik/docusaurus-config/css/index.css")],
|
||||
},
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// Shared utilities and types
|
||||
import { type GlossaryItem, isGlossaryItem, isGlossaryPath } from "../utils/glossaryUtils";
|
||||
import { isLearningCenterItem, isLearningCenterPath } from "../utils/learningCenter/utils";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import GlossaryDocCardList from "./GlossaryDocCardList";
|
||||
import LearningCenterDocCardList from "./learningCenter/LearningCenterDocCardList";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
// Docusaurus core imports
|
||||
import type { PropSidebarItem } from "@docusaurus/plugin-content-docs";
|
||||
import * as DocsClient from "@docusaurus/plugin-content-docs/client";
|
||||
import {
|
||||
filterDocCardListItems,
|
||||
useCurrentSidebarSiblings,
|
||||
@@ -21,6 +24,15 @@ const EMPTY_SIDEBAR_ITEMS: PropSidebarItem[] = [];
|
||||
|
||||
// Type aliases for clarity
|
||||
type SidebarDocLike = Extract<PropSidebarItem, { type: "link" }>;
|
||||
const EMPTY_LINK_ITEMS: SidebarDocLike[] = [];
|
||||
|
||||
interface DocsSidebarContext {
|
||||
items: PropSidebarItem[];
|
||||
}
|
||||
|
||||
const useDocsSidebarSafe: () => DocsSidebarContext | null =
|
||||
(DocsClient as unknown as { useDocsSidebar?: () => DocsSidebarContext | null })
|
||||
.useDocsSidebar ?? (() => null);
|
||||
|
||||
/**
|
||||
* Type-safe property existence checker with proper typing
|
||||
@@ -45,6 +57,29 @@ function getStableKey(item: GlossaryItem | PropSidebarItem, idx: number): string
|
||||
return idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect sidebar link items matching a predicate.
|
||||
*/
|
||||
function collectSidebarLinks(
|
||||
items: readonly PropSidebarItem[],
|
||||
match: (item: PropSidebarItem) => boolean,
|
||||
): SidebarDocLike[] {
|
||||
const collected: SidebarDocLike[] = [];
|
||||
|
||||
const process = (item: PropSidebarItem) => {
|
||||
if (item.type === "link" && match(item)) {
|
||||
collected.push(item as SidebarDocLike);
|
||||
return;
|
||||
}
|
||||
if (item.type === "category" && item.items) {
|
||||
item.items.forEach(process);
|
||||
}
|
||||
};
|
||||
|
||||
items.forEach(process);
|
||||
return collected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard documentation card item for non-glossary content
|
||||
*/
|
||||
@@ -58,35 +93,41 @@ function DocCardListItem({ item }: { item: React.ComponentProps<typeof DocCard>[
|
||||
|
||||
/**
|
||||
* Enhanced DocCardList component that delegates to specialized components based on content type.
|
||||
* Provides both standard documentation card rendering and specialized glossary functionality.
|
||||
* Provides both standard documentation card rendering and specialized glossary/learning center functionality.
|
||||
*/
|
||||
export default function DocCardList(props: Props): ReactNode {
|
||||
const { items, className } = props;
|
||||
|
||||
const pathname = useLocation()?.pathname ?? "";
|
||||
const isGlossary = isGlossaryPath(pathname);
|
||||
const isLearningCenter = isLearningCenterPath(pathname);
|
||||
|
||||
const sidebarSiblings = useCurrentSidebarSiblings();
|
||||
const siblings = sidebarSiblings ?? EMPTY_SIDEBAR_ITEMS;
|
||||
const docsSidebar = useDocsSidebarSafe();
|
||||
const fullSidebarItems = docsSidebar?.items ?? EMPTY_SIDEBAR_ITEMS;
|
||||
|
||||
// Extract glossary terms from sidebar structure (always computed, but only used for glossary pages)
|
||||
// Extract glossary terms from sidebar structure when on glossary pages.
|
||||
const glossaryPool = useMemo<SidebarDocLike[]>(() => {
|
||||
const terms: SidebarDocLike[] = [];
|
||||
if (!isGlossary) {
|
||||
return EMPTY_LINK_ITEMS;
|
||||
}
|
||||
return collectSidebarLinks(siblings, isGlossaryItem);
|
||||
}, [isGlossary, siblings]);
|
||||
|
||||
// Recursively process sidebar items to find glossary terms
|
||||
const processItem = (item: PropSidebarItem) => {
|
||||
if (isGlossaryItem(item) && item.type === "link") {
|
||||
terms.push(item as SidebarDocLike);
|
||||
} else if (item.type === "category" && item.items) {
|
||||
item.items.forEach(processItem);
|
||||
}
|
||||
};
|
||||
// Extract learning center resources from sidebar structure when on learning center pages.
|
||||
const resourcePool = useMemo<SidebarDocLike[]>(() => {
|
||||
if (!isLearningCenter) {
|
||||
return EMPTY_LINK_ITEMS;
|
||||
}
|
||||
|
||||
siblings.forEach(processItem);
|
||||
return terms;
|
||||
}, [siblings]);
|
||||
// On learning-path detail pages, current siblings can be limited to the
|
||||
// "path" category only. Use full sidebar tree for complete resource extraction.
|
||||
const allItems = fullSidebarItems.length > 0 ? fullSidebarItems : (items ?? siblings);
|
||||
return collectSidebarLinks(allItems, isLearningCenterItem);
|
||||
}, [fullSidebarItems, isLearningCenter, items, siblings]);
|
||||
|
||||
// Standard documentation card items (always computed, but only used for non-glossary pages)
|
||||
// Standard documentation card items for non-specialized pages.
|
||||
const baseItems = useMemo(() => filterDocCardListItems(items ?? siblings), [items, siblings]);
|
||||
|
||||
// For glossary pages, delegate to specialized GlossaryDocCardList component
|
||||
@@ -98,7 +139,16 @@ export default function DocCardList(props: Props): ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
// Standard documentation card rendering for non-glossary pages
|
||||
// For learning center pages, delegate to specialized LearningCenterDocCardList component
|
||||
if (isLearningCenter) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<LearningCenterDocCardList resourcePool={resourcePool} className={className} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// Standard documentation card rendering for non-specialized pages
|
||||
return (
|
||||
<section className={clsx("row", className)}>
|
||||
{baseItems.map((item, idx) => (
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { LEARNING_PATHS } from "../../../components/LearningCenter/learningPathsConfig";
|
||||
import commonStyles from "../../../components/LearningCenter/styling/common.module.css";
|
||||
import {
|
||||
applyLearningCenterFilters,
|
||||
type DifficultyLevel,
|
||||
type LearningCenterResource,
|
||||
} from "../../utils/learningCenter/utils";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
import ResourceSectionList from "./components/ResourceSectionList";
|
||||
import LearningCenterHelper from "./LearningCenterHelper";
|
||||
import LearningCenterLanding from "./LearningCenterLanding";
|
||||
import LearningPathExperience from "./LearningPathExperience";
|
||||
import { consumeLearningCenterNavigationState } from "./navigationState";
|
||||
import {
|
||||
buildLearningResources,
|
||||
buildResourceCache,
|
||||
buildSidebarItemMap,
|
||||
dedupeResourcePool,
|
||||
} from "./resourceData";
|
||||
import type { SidebarDocLike } from "./types";
|
||||
|
||||
import { useLocation } from "@docusaurus/router";
|
||||
import clsx from "clsx";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
interface LearningCenterDocCardListProps {
|
||||
resourcePool: SidebarDocLike[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LEARNING_PATH_ROUTE_REGEX = /\/learning-center\/path\/([^/]+)/;
|
||||
const LEARNING_CENTER_ARTICLES_ROUTE_REGEX = /\/learning-center\/articles\/?$/;
|
||||
const LEARNING_CENTER_INDEX_ROUTE_REGEX = /\/learning-center\/?$/;
|
||||
const DIFFICULTY_ORDER: Record<DifficultyLevel, number> = {
|
||||
beginner: 0,
|
||||
intermediate: 1,
|
||||
advanced: 2,
|
||||
};
|
||||
|
||||
function getLearningPathFromPathname(pathname: string): string | null {
|
||||
const match = pathname.match(LEARNING_PATH_ROUTE_REGEX);
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function sortLearningPathResources(resources: LearningCenterResource[]): LearningCenterResource[] {
|
||||
return resources.toSorted((a, b) => {
|
||||
const difficultyDiff = DIFFICULTY_ORDER[a.difficulty] - DIFFICULTY_ORDER[b.difficulty];
|
||||
if (difficultyDiff !== 0) {
|
||||
return difficultyDiff;
|
||||
}
|
||||
|
||||
const categoryDiff = a.category.localeCompare(b.category);
|
||||
if (categoryDiff !== 0) {
|
||||
return categoryDiff;
|
||||
}
|
||||
|
||||
return a.resourceName.localeCompare(b.resourceName);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for rendering learning center resources with search, filtering,
|
||||
* and dedicated learning-path pages.
|
||||
*/
|
||||
export default function LearningCenterDocCardList({
|
||||
resourcePool,
|
||||
className,
|
||||
}: LearningCenterDocCardListProps): ReactNode {
|
||||
const location = useLocation();
|
||||
const pathname = location?.pathname ?? "";
|
||||
const learningPathFromRoute = useMemo(() => getLearningPathFromPathname(pathname), [pathname]);
|
||||
const isLearningCenterArticlesPage = useMemo(
|
||||
() => LEARNING_CENTER_ARTICLES_ROUTE_REGEX.test(pathname),
|
||||
[pathname],
|
||||
);
|
||||
const isLearningCenterIndexPage = useMemo(
|
||||
() => LEARNING_CENTER_INDEX_ROUTE_REGEX.test(pathname),
|
||||
[pathname],
|
||||
);
|
||||
const initialNavigationState = useMemo(
|
||||
() => (isLearningCenterArticlesPage ? consumeLearningCenterNavigationState() : {}),
|
||||
[isLearningCenterArticlesPage],
|
||||
);
|
||||
|
||||
const uniqueResourcePool = useMemo(() => dedupeResourcePool(resourcePool), [resourcePool]);
|
||||
const resourceCache = useMemo(
|
||||
() => buildResourceCache(uniqueResourcePool),
|
||||
[uniqueResourcePool],
|
||||
);
|
||||
const sidebarItemMap = useMemo(
|
||||
() => buildSidebarItemMap(uniqueResourcePool),
|
||||
[uniqueResourcePool],
|
||||
);
|
||||
const learningResources = useMemo(
|
||||
() => buildLearningResources(uniqueResourcePool, resourceCache),
|
||||
[uniqueResourcePool, resourceCache],
|
||||
);
|
||||
|
||||
const activeLearningPath = useMemo(
|
||||
() => LEARNING_PATHS.find((path) => path.filterTag === learningPathFromRoute) ?? null,
|
||||
[learningPathFromRoute],
|
||||
);
|
||||
|
||||
const learningPathResources = useMemo(() => {
|
||||
if (!learningPathFromRoute) {
|
||||
return [];
|
||||
}
|
||||
return sortLearningPathResources(
|
||||
applyLearningCenterFilters(learningResources, {
|
||||
selectedLearningPath: learningPathFromRoute,
|
||||
}),
|
||||
);
|
||||
}, [learningResources, learningPathFromRoute]);
|
||||
|
||||
const learningPathTitle = activeLearningPath?.title || "Learning Path";
|
||||
const learningPathDescription =
|
||||
activeLearningPath?.description ||
|
||||
"Follow this curated track to focus on a single area with less distraction.";
|
||||
const learningPathDifficulty = activeLearningPath?.difficulty;
|
||||
const isLearningPathPage = Boolean(learningPathFromRoute);
|
||||
const hasValidLearningPath = Boolean(activeLearningPath);
|
||||
const articlesPageResources = useMemo(() => {
|
||||
if (!isLearningCenterArticlesPage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return applyLearningCenterFilters(learningResources, {
|
||||
query: initialNavigationState.filter,
|
||||
selectedCategories: initialNavigationState.categories,
|
||||
selectedDifficulty: initialNavigationState.difficulty,
|
||||
});
|
||||
}, [isLearningCenterArticlesPage, learningResources, initialNavigationState]);
|
||||
|
||||
const renderResources = useCallback(
|
||||
(filteredResources: LearningCenterResource[], searchFilter: string) => {
|
||||
return (
|
||||
<ResourceSectionList
|
||||
resources={filteredResources}
|
||||
sidebarItemMap={sidebarItemMap}
|
||||
resourceCache={resourceCache}
|
||||
searchFilter={searchFilter}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[sidebarItemMap, resourceCache],
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div className="alert alert--warning margin-bottom--md">
|
||||
<h4>Learning Center temporarily unavailable</h4>
|
||||
<p>
|
||||
There was an error loading the learning resources. Please try refreshing the
|
||||
page.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isLearningPathPage ? (
|
||||
<LearningPathExperience
|
||||
className={className}
|
||||
title={learningPathTitle}
|
||||
description={learningPathDescription}
|
||||
difficulty={learningPathDifficulty}
|
||||
resources={learningPathResources}
|
||||
sidebarItemMap={sidebarItemMap}
|
||||
resourceCache={resourceCache}
|
||||
hasValidPath={hasValidLearningPath}
|
||||
/>
|
||||
) : isLearningCenterIndexPage ? (
|
||||
<LearningCenterLanding
|
||||
resources={learningResources}
|
||||
learningPaths={LEARNING_PATHS}
|
||||
/>
|
||||
) : isLearningCenterArticlesPage ? (
|
||||
<div className={clsx(commonStyles.learningCenter, className)}>
|
||||
{renderResources(articlesPageResources, initialNavigationState.filter ?? "")}
|
||||
</div>
|
||||
) : (
|
||||
<LearningCenterHelper resources={learningResources} className={className}>
|
||||
{renderResources}
|
||||
</LearningCenterHelper>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import CategoryNav from "../../../components/LearningCenter/CategoryNav";
|
||||
import DifficultyFilter from "../../../components/LearningCenter/DifficultyFilter";
|
||||
import FilterInput from "../../../components/LearningCenter/FilterInput";
|
||||
import commonStyles from "../../../components/LearningCenter/styling/common.module.css";
|
||||
import filterStyles from "../../../components/LearningCenter/styling/filters.module.css";
|
||||
import type { DifficultyLevel, LearningCenterResource } from "../../utils/learningCenter/utils";
|
||||
import CategoryDescriptions from "./components/CategoryDescriptions";
|
||||
import NoResults from "./components/NoResults";
|
||||
import { useLearningCenterFilter } from "./useLearningCenterFilter";
|
||||
|
||||
import clsx from "clsx";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Props for LearningCenterHelper component
|
||||
*/
|
||||
export interface LearningCenterHelperProps {
|
||||
/** Array of learning center resources to display and filter */
|
||||
resources: LearningCenterResource[];
|
||||
/** Render prop that receives filtered resources and search filter */
|
||||
children: (filteredResources: LearningCenterResource[], searchFilter: string) => ReactNode;
|
||||
/** Optional CSS class name for styling */
|
||||
className?: string;
|
||||
/** Initial text filter state, typically from URL query params */
|
||||
initialFilter?: string;
|
||||
/** Initial category filter state, typically from URL query params */
|
||||
initialCategories?: string[];
|
||||
/** Initial difficulty filter state, typically from URL query params */
|
||||
initialDifficulty?: DifficultyLevel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* LearningCenterHelper provides search and filtering functionality for learning resources.
|
||||
* It uses a render prop pattern to allow flexible rendering while managing state and interactions.
|
||||
*/
|
||||
export function LearningCenterHelper({
|
||||
resources,
|
||||
children,
|
||||
className,
|
||||
initialFilter,
|
||||
initialCategories,
|
||||
initialDifficulty,
|
||||
}: LearningCenterHelperProps): ReactNode {
|
||||
const {
|
||||
filter,
|
||||
debouncedFilter,
|
||||
setFilter,
|
||||
clearFilter,
|
||||
selectedCategories,
|
||||
toggleCategory,
|
||||
selectedDifficulty,
|
||||
setDifficulty,
|
||||
filteredResources,
|
||||
availableCategories,
|
||||
availableDifficulties,
|
||||
} = useLearningCenterFilter(resources, {
|
||||
initialFilter,
|
||||
initialCategories,
|
||||
initialDifficulty,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={clsx(commonStyles.learningCenter, className)}>
|
||||
<section className={filterStyles.filterModeSection} aria-label="Browse by filters">
|
||||
<FilterInput value={filter} onChange={setFilter} onClear={clearFilter} />
|
||||
|
||||
{availableCategories.length > 1 && (
|
||||
<CategoryNav
|
||||
availableCategories={availableCategories}
|
||||
selectedCategories={selectedCategories}
|
||||
onToggleCategory={toggleCategory}
|
||||
/>
|
||||
)}
|
||||
|
||||
{availableDifficulties.length > 1 && (
|
||||
<DifficultyFilter
|
||||
availableDifficulties={availableDifficulties}
|
||||
selectedDifficulty={selectedDifficulty}
|
||||
onSelectDifficulty={setDifficulty}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<CategoryDescriptions selectedCategories={selectedCategories} />
|
||||
|
||||
<div className={commonStyles.resourceList}>
|
||||
{filteredResources.length > 0 ? (
|
||||
children(filteredResources, debouncedFilter)
|
||||
) : (
|
||||
<NoResults />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LearningCenterHelper;
|
||||
@@ -0,0 +1,87 @@
|
||||
import LearningPaths from "../../../components/LearningCenter/LearningPaths";
|
||||
import type { LearningPathDef } from "../../../components/LearningCenter/learningPathsConfig";
|
||||
import commonStyles from "../../../components/LearningCenter/styling/common.module.css";
|
||||
import landingStyles from "../../../components/LearningCenter/styling/landing.module.css";
|
||||
import {
|
||||
type DifficultyLevel,
|
||||
extractAvailableCategories,
|
||||
extractAvailableDifficulties,
|
||||
type LearningCenterResource,
|
||||
} from "../../utils/learningCenter/utils";
|
||||
import LandingAllArticlesCta from "./components/LandingAllArticlesCta";
|
||||
import LandingFilterPanel from "./components/LandingFilterPanel";
|
||||
import { writeLearningCenterNavigationState } from "./navigationState";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
interface LearningCenterLandingProps {
|
||||
resources: LearningCenterResource[];
|
||||
learningPaths: LearningPathDef[];
|
||||
}
|
||||
|
||||
const ARTICLES_PAGE_PATH = "/core/learning-center/articles/";
|
||||
|
||||
export default function LearningCenterLanding({
|
||||
resources,
|
||||
learningPaths,
|
||||
}: LearningCenterLandingProps): ReactNode {
|
||||
const intro =
|
||||
"Start with a curated path, or use one of the alternatives below to find exactly what you need.";
|
||||
const primaryText =
|
||||
"Learning paths are the easiest way to progress with context and a clear order.";
|
||||
const filterText =
|
||||
"Need something specific? Search by keyword, then narrow by category or experience level.";
|
||||
const allArticlesText = "Or browse everything:";
|
||||
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const availableCategories = useMemo(() => extractAvailableCategories(resources), [resources]);
|
||||
const availableDifficulties = useMemo(
|
||||
() => extractAvailableDifficulties(resources),
|
||||
[resources],
|
||||
);
|
||||
|
||||
const navigateWithState = useCallback(
|
||||
(state: {
|
||||
filter?: string;
|
||||
categories?: string[];
|
||||
difficulty?: DifficultyLevel | null;
|
||||
}) => {
|
||||
writeLearningCenterNavigationState(state);
|
||||
window.location.assign(ARTICLES_PAGE_PATH);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={commonStyles.learningCenter}>
|
||||
<section className={landingStyles.discoveryIntro}>
|
||||
<h2 className={landingStyles.discoveryTitle}>Learning Center</h2>
|
||||
<p className={landingStyles.discoveryDescription}>{intro}</p>
|
||||
</section>
|
||||
|
||||
<section className={landingStyles.discoveryPrimary}>
|
||||
<h3 className={landingStyles.discoverySubTitle}>Learning paths</h3>
|
||||
<p className={landingStyles.discoveryMethodBody}>{primaryText}</p>
|
||||
<LearningPaths paths={learningPaths} resources={resources} hideTitle />
|
||||
</section>
|
||||
|
||||
<section className={landingStyles.discoveryAlternatives}>
|
||||
<p className={landingStyles.discoveryKicker}>Looking for something specific?</p>
|
||||
<article className={landingStyles.discoveryOptionCard}>
|
||||
<h3 className={landingStyles.discoveryOptionTitle}>Filter articles</h3>
|
||||
<p className={landingStyles.discoveryMethodBody}>{filterText}</p>
|
||||
<LandingFilterPanel
|
||||
searchValue={searchValue}
|
||||
setSearchValue={setSearchValue}
|
||||
availableCategories={availableCategories}
|
||||
availableDifficulties={availableDifficulties}
|
||||
onNavigate={navigateWithState}
|
||||
/>
|
||||
</article>
|
||||
|
||||
<LandingAllArticlesCta to={ARTICLES_PAGE_PATH} text={allArticlesText} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import commonStyles from "../../../components/LearningCenter/styling/common.module.css";
|
||||
import pathStyles from "../../../components/LearningCenter/styling/pathExperience.module.css";
|
||||
import {
|
||||
type DifficultyLevel,
|
||||
getDifficultyLabel,
|
||||
type LearningCenterResource,
|
||||
} from "../../utils/learningCenter/utils";
|
||||
import ResourceCard from "./ResourceCard";
|
||||
import type { ResourceCache, SidebarItemMap } from "./types";
|
||||
|
||||
import Link from "@docusaurus/Link";
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
||||
export interface LearningPathExperienceProps {
|
||||
className?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
difficulty?: DifficultyLevel;
|
||||
resources: LearningCenterResource[];
|
||||
sidebarItemMap: SidebarItemMap;
|
||||
resourceCache: ResourceCache;
|
||||
hasValidPath: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated, focused learning-path page content.
|
||||
*/
|
||||
export function LearningPathExperience({
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
difficulty,
|
||||
resources,
|
||||
sidebarItemMap,
|
||||
resourceCache,
|
||||
hasValidPath,
|
||||
}: LearningPathExperienceProps) {
|
||||
return (
|
||||
<div className={clsx(pathStyles.learningPathExperience, className)}>
|
||||
<Link className={pathStyles.pathBackLink} to="/core/learning-center/">
|
||||
Back to Learning Center
|
||||
</Link>
|
||||
|
||||
<section className={pathStyles.pathHero}>
|
||||
<p className={pathStyles.pathHeroEyebrow}>Learning Path</p>
|
||||
<h1 className={pathStyles.pathHeroTitle}>{title}</h1>
|
||||
<p className={pathStyles.pathHeroDescription}>{description}</p>
|
||||
<div className={pathStyles.pathHeroMeta}>
|
||||
{difficulty ? (
|
||||
<span
|
||||
className={clsx(
|
||||
pathStyles.pathHeroMetaItem,
|
||||
pathStyles[`pathHeroMetaItemDifficulty-${difficulty}`],
|
||||
)}
|
||||
>
|
||||
{getDifficultyLabel(difficulty)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className={pathStyles.pathHeroMetaItem}>
|
||||
{resources.length} article{resources.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!hasValidPath ? (
|
||||
<div className="alert alert--warning margin-bottom--md">
|
||||
Unknown learning path. Please return to the Learning Center and choose a valid
|
||||
track.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{resources.length > 0 ? (
|
||||
<section className={pathStyles.pathCurriculumSection}>
|
||||
<h2 className={pathStyles.pathCurriculumTitle}>Path Curriculum</h2>
|
||||
<div className={commonStyles.resourceGrid}>
|
||||
{resources.map((resource, index) => {
|
||||
const sidebarItem = sidebarItemMap.get(resource.id);
|
||||
return sidebarItem ? (
|
||||
<ResourceCard
|
||||
key={resource.id}
|
||||
item={sidebarItem}
|
||||
resourceCache={resourceCache}
|
||||
pathStepNumber={index + 1}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LearningPathExperience;
|
||||
@@ -0,0 +1,107 @@
|
||||
import styles from "../../../components/LearningCenter/styling/resourceCard.module.css";
|
||||
import { getDifficultyLabel } from "../../utils/learningCenter/utils";
|
||||
import type { ResourceCache, SidebarDocLike } from "./types";
|
||||
|
||||
import Link from "@docusaurus/Link";
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Highlights matching search terms in text.
|
||||
*/
|
||||
function highlightText(
|
||||
text: string,
|
||||
searchFilter: string,
|
||||
keyPrefix: string,
|
||||
): (string | React.ReactElement)[] {
|
||||
if (!searchFilter) return [text];
|
||||
|
||||
const escaped = searchFilter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`(${escaped})`, "gi");
|
||||
const matches = text.split(regex);
|
||||
|
||||
return matches.map((part, i) => {
|
||||
if (part.toLowerCase() === searchFilter.toLowerCase()) {
|
||||
return (
|
||||
<mark key={`${keyPrefix}-${i}`} className={styles.searchHighlight}>
|
||||
{part}
|
||||
</mark>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
export interface ResourceCardProps {
|
||||
item: SidebarDocLike;
|
||||
resourceCache: ResourceCache;
|
||||
searchFilter?: string;
|
||||
pathStepNumber?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single learning resource card that links to the article page.
|
||||
*/
|
||||
export function ResourceCard({
|
||||
item,
|
||||
resourceCache,
|
||||
searchFilter = "",
|
||||
pathStepNumber,
|
||||
}: ResourceCardProps) {
|
||||
const cachedData = item.docId ? resourceCache[item.docId] : null;
|
||||
|
||||
if (!item.docId || !cachedData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { resourceName, shortDescription, difficulty, estimatedTime } = cachedData;
|
||||
const articleHref = item.href || "";
|
||||
if (!articleHref) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={articleHref} className={styles.resourceCardLink}>
|
||||
<article className={styles.resourceCard}>
|
||||
{pathStepNumber ? (
|
||||
<span className={styles.pathStepBadge}>Step {pathStepNumber}</span>
|
||||
) : null}
|
||||
<div className={styles.resourceCardHeader}>
|
||||
<h3 className={styles.resourceTitle}>
|
||||
{highlightText(resourceName, searchFilter, "title")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className={styles.resourceMeta}>
|
||||
<span
|
||||
className={clsx(styles.badge, styles.difficultyBadge, styles[difficulty])}
|
||||
>
|
||||
{getDifficultyLabel(difficulty)}
|
||||
</span>
|
||||
{estimatedTime ? (
|
||||
<span className={clsx(styles.badge, styles.timeBadge)}>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M8 0a8 8 0 110 16A8 8 0 018 0zm0 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM8 3a.75.75 0 01.75.75v3.69l2.28 2.28a.75.75 0 11-1.06 1.06l-2.5-2.5A.75.75 0 017.25 8V3.75A.75.75 0 018 3z" />
|
||||
</svg>
|
||||
{estimatedTime}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className={styles.resourceShort}>
|
||||
{shortDescription
|
||||
? highlightText(shortDescription, searchFilter, "short")
|
||||
: "Description not provided."}
|
||||
</p>
|
||||
</article>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResourceCard;
|
||||
@@ -0,0 +1,22 @@
|
||||
import commonStyles from "../../../../components/LearningCenter/styling/common.module.css";
|
||||
import { getCategoryDescription } from "../../../utils/learningCenter/categoryDescriptions";
|
||||
|
||||
interface CategoryDescriptionsProps {
|
||||
selectedCategories: string[];
|
||||
}
|
||||
|
||||
export default function CategoryDescriptions({ selectedCategories }: CategoryDescriptionsProps) {
|
||||
if (selectedCategories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={commonStyles.categoryDescriptions}>
|
||||
{selectedCategories.map((category) => (
|
||||
<p key={category} className={commonStyles.categoryDescriptionText}>
|
||||
<strong>{category}</strong>: {getCategoryDescription(category)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import landingStyles from "../../../../components/LearningCenter/styling/landing.module.css";
|
||||
|
||||
import Link from "@docusaurus/Link";
|
||||
|
||||
interface LandingAllArticlesCtaProps {
|
||||
to: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function LandingAllArticlesCta({ to, text }: LandingAllArticlesCtaProps) {
|
||||
return (
|
||||
<p className={landingStyles.discoveryInlineCta}>
|
||||
{text}{" "}
|
||||
<Link className={landingStyles.allArticlesLinkInline} to={to}>
|
||||
Open full article index
|
||||
</Link>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import filtersStyles from "../../../../components/LearningCenter/styling/filters.module.css";
|
||||
import { formatCategory } from "../../../../components/LearningCenter/utils";
|
||||
import { type DifficultyLevel, getDifficultyLabel } from "../../../utils/learningCenter/utils";
|
||||
|
||||
interface LandingFilterPanelProps {
|
||||
searchValue: string;
|
||||
setSearchValue: (value: string) => void;
|
||||
availableCategories: string[];
|
||||
availableDifficulties: DifficultyLevel[];
|
||||
onNavigate: (state: {
|
||||
filter?: string;
|
||||
categories?: string[];
|
||||
difficulty?: DifficultyLevel | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export default function LandingFilterPanel({
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
availableCategories,
|
||||
availableDifficulties,
|
||||
onNavigate,
|
||||
}: LandingFilterPanelProps) {
|
||||
return (
|
||||
<div className={filtersStyles.filterModeSection}>
|
||||
<form
|
||||
className={filtersStyles.filter}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onNavigate({ filter: searchValue });
|
||||
}}
|
||||
role="search"
|
||||
aria-label="Search articles"
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search articles..."
|
||||
className={filtersStyles.filterInput}
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
/>
|
||||
<button type="submit" className={filtersStyles.searchButton}>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{availableCategories.length > 0 ? (
|
||||
<div className={filtersStyles.quickBrowseRow}>
|
||||
<span className={filtersStyles.navLabel}>Categories:</span>
|
||||
{availableCategories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
className={filtersStyles.categoryButton}
|
||||
onClick={() => onNavigate({ categories: [category] })}
|
||||
>
|
||||
{formatCategory(category)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{availableDifficulties.length > 0 ? (
|
||||
<div className={filtersStyles.quickBrowseRow}>
|
||||
<span className={filtersStyles.navLabel}>Experience Level:</span>
|
||||
{availableDifficulties.map((difficulty) => (
|
||||
<button
|
||||
key={difficulty}
|
||||
type="button"
|
||||
className={`${filtersStyles.difficultyButton} ${filtersStyles[`difficulty-${difficulty}`]}`}
|
||||
onClick={() => onNavigate({ difficulty })}
|
||||
>
|
||||
{getDifficultyLabel(difficulty)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import commonStyles from "../../../../components/LearningCenter/styling/common.module.css";
|
||||
|
||||
export default function NoResults() {
|
||||
return (
|
||||
<div className={commonStyles.noResults}>
|
||||
<p>No resources match your filter criteria.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import commonStyles from "../../../../components/LearningCenter/styling/common.module.css";
|
||||
import type { LearningCenterResource } from "../../../utils/learningCenter/utils";
|
||||
import ResourceCard from "../ResourceCard";
|
||||
import type { ResourceCache, SidebarItemMap } from "../types";
|
||||
|
||||
interface ResourceSectionListProps {
|
||||
resources: LearningCenterResource[];
|
||||
sidebarItemMap: SidebarItemMap;
|
||||
resourceCache: ResourceCache;
|
||||
searchFilter: string;
|
||||
}
|
||||
|
||||
function groupResourcesByCategory(
|
||||
resources: LearningCenterResource[],
|
||||
): Array<[string, LearningCenterResource[]]> {
|
||||
const byCategory = new Map<string, LearningCenterResource[]>();
|
||||
resources.forEach((resource) => {
|
||||
const category = resource.category || "General";
|
||||
const grouped = byCategory.get(category) ?? [];
|
||||
grouped.push(resource);
|
||||
byCategory.set(category, grouped);
|
||||
});
|
||||
|
||||
return Array.from(byCategory.entries())
|
||||
.toSorted(([a], [b]) => a.localeCompare(b))
|
||||
.map(([category, grouped]) => [
|
||||
category,
|
||||
grouped.toSorted((a, b) => a.resourceName.localeCompare(b.resourceName)),
|
||||
]);
|
||||
}
|
||||
|
||||
export default function ResourceSectionList({
|
||||
resources,
|
||||
sidebarItemMap,
|
||||
resourceCache,
|
||||
searchFilter,
|
||||
}: ResourceSectionListProps) {
|
||||
const resourcesByCategory = groupResourcesByCategory(resources);
|
||||
|
||||
return (
|
||||
<div className={commonStyles.resourceList}>
|
||||
{resourcesByCategory.map(([category, categoryResources]) => (
|
||||
<div key={category} className={commonStyles.section}>
|
||||
<h2 className={commonStyles.sectionTitle}>{category}</h2>
|
||||
<div className={commonStyles.resourceGrid}>
|
||||
{categoryResources.map((resource) => {
|
||||
const sidebarItem = sidebarItemMap.get(resource.id);
|
||||
return sidebarItem ? (
|
||||
<ResourceCard
|
||||
key={resource.id}
|
||||
item={sidebarItem}
|
||||
resourceCache={resourceCache}
|
||||
searchFilter={searchFilter}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { DIFFICULTY_LEVELS, type DifficultyLevel } from "../../utils/learningCenter/utils";
|
||||
|
||||
export interface LearningCenterNavigationState {
|
||||
filter?: string;
|
||||
categories?: string[];
|
||||
difficulty?: DifficultyLevel | null;
|
||||
}
|
||||
|
||||
const NAVIGATION_STATE_KEY = "authentik.learning-center.navigation-state";
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
value.filter((item): item is string => typeof item === "string" && item.trim() !== ""),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDifficulty(value: unknown): DifficultyLevel | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = value.toLowerCase();
|
||||
return DIFFICULTY_LEVELS.includes(parsed as DifficultyLevel)
|
||||
? (parsed as DifficultyLevel)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function writeLearningCenterNavigationState(state: LearningCenterNavigationState): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
filter: typeof state.filter === "string" ? state.filter.trim() : "",
|
||||
categories: normalizeStringArray(state.categories),
|
||||
difficulty: normalizeDifficulty(state.difficulty),
|
||||
};
|
||||
|
||||
window.sessionStorage.setItem(NAVIGATION_STATE_KEY, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function consumeLearningCenterNavigationState(): LearningCenterNavigationState {
|
||||
if (typeof window === "undefined") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rawState = window.sessionStorage.getItem(NAVIGATION_STATE_KEY);
|
||||
window.sessionStorage.removeItem(NAVIGATION_STATE_KEY);
|
||||
|
||||
if (!rawState) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawState) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
filter: typeof parsed.filter === "string" ? parsed.filter.trim() : "",
|
||||
categories: normalizeStringArray(parsed.categories),
|
||||
difficulty: normalizeDifficulty(parsed.difficulty),
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
extractLearningPathsFromProps,
|
||||
type LearningCenterResource,
|
||||
safeDifficultyExtract,
|
||||
safeResourceTypeExtract,
|
||||
safeStringArrayExtract,
|
||||
safeStringExtract,
|
||||
} from "../../utils/learningCenter/utils";
|
||||
import type { ResourceCache, SidebarDocLike, SidebarItemMap } from "./types";
|
||||
|
||||
import type { PropSidebarItem } from "@docusaurus/plugin-content-docs";
|
||||
|
||||
/**
|
||||
* Safely extracts label from sidebar item.
|
||||
*/
|
||||
function getLabelFromItem(item: PropSidebarItem): string {
|
||||
if ("label" in item && typeof item.label === "string") return item.label;
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes duplicate sidebar resources by stable key.
|
||||
*/
|
||||
export function dedupeResourcePool(resourcePool: SidebarDocLike[]): SidebarDocLike[] {
|
||||
const seen = new Set<string>();
|
||||
return resourcePool.filter((item) => {
|
||||
const key = item.docId || item.href || getLabelFromItem(item);
|
||||
if (!key || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds metadata cache for each docId.
|
||||
*/
|
||||
export function buildResourceCache(uniqueResourcePool: SidebarDocLike[]): ResourceCache {
|
||||
const cache: ResourceCache = {};
|
||||
|
||||
uniqueResourcePool
|
||||
.filter((item): item is SidebarDocLike & { docId: string } => Boolean(item.docId))
|
||||
.forEach((item) => {
|
||||
const sidebarProps = item.customProps ?? {};
|
||||
const fallbackLabel = getLabelFromItem(item);
|
||||
|
||||
cache[item.docId] = {
|
||||
resourceName:
|
||||
safeStringExtract(sidebarProps.resourceName) ||
|
||||
fallbackLabel ||
|
||||
item.docId ||
|
||||
"Resource",
|
||||
category: safeStringExtract(sidebarProps.category, "General"),
|
||||
learningPaths: extractLearningPathsFromProps(sidebarProps),
|
||||
shortDescription: safeStringExtract(sidebarProps.shortDescription),
|
||||
longDescription: safeStringExtract(sidebarProps.longDescription),
|
||||
difficulty: safeDifficultyExtract(sidebarProps.difficulty),
|
||||
resourceType: safeResourceTypeExtract(sidebarProps.resourceType),
|
||||
estimatedTime: safeStringExtract(sidebarProps.estimatedTime),
|
||||
prerequisites: safeStringArrayExtract(sidebarProps.prerequisites),
|
||||
relatedResources: safeStringArrayExtract(sidebarProps.relatedResources),
|
||||
};
|
||||
});
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds O(1) lookup map for sidebar items by docId.
|
||||
*/
|
||||
export function buildSidebarItemMap(uniqueResourcePool: SidebarDocLike[]): SidebarItemMap {
|
||||
return new Map(
|
||||
uniqueResourcePool
|
||||
.filter((item): item is SidebarDocLike & { docId: string } => Boolean(item.docId))
|
||||
.map((item) => [item.docId, item]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts sidebar metadata into standardized learning resource objects.
|
||||
*/
|
||||
export function buildLearningResources(
|
||||
uniqueResourcePool: SidebarDocLike[],
|
||||
resourceCache: ResourceCache,
|
||||
): LearningCenterResource[] {
|
||||
return uniqueResourcePool
|
||||
.filter((item): item is SidebarDocLike & { docId: string } => Boolean(item.docId))
|
||||
.map((item) => {
|
||||
const cached = resourceCache[item.docId];
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: item.docId,
|
||||
resourceName: cached.resourceName,
|
||||
category: cached.category,
|
||||
learningPaths: cached.learningPaths,
|
||||
shortDescription: cached.shortDescription,
|
||||
longDescription: cached.longDescription || undefined,
|
||||
difficulty: cached.difficulty,
|
||||
resourceType: cached.resourceType,
|
||||
estimatedTime: cached.estimatedTime || undefined,
|
||||
prerequisites: cached.prerequisites.length > 0 ? cached.prerequisites : undefined,
|
||||
relatedResources:
|
||||
cached.relatedResources.length > 0 ? cached.relatedResources : undefined,
|
||||
};
|
||||
})
|
||||
.filter((resource): resource is NonNullable<typeof resource> => resource !== null);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
DifficultyLevel,
|
||||
LearningCenterResource,
|
||||
ResourceType,
|
||||
} from "../../utils/learningCenter/utils";
|
||||
|
||||
import type { PropSidebarItem } from "@docusaurus/plugin-content-docs";
|
||||
|
||||
export type SidebarDocLike = Extract<PropSidebarItem, { type: "link" }>;
|
||||
|
||||
export interface ResourceCacheEntry {
|
||||
resourceName: string;
|
||||
category: string;
|
||||
learningPaths: string[];
|
||||
shortDescription: string;
|
||||
longDescription: string;
|
||||
difficulty: DifficultyLevel;
|
||||
resourceType: ResourceType;
|
||||
estimatedTime: string;
|
||||
prerequisites: string[];
|
||||
relatedResources: string[];
|
||||
}
|
||||
|
||||
export type ResourceCache = Record<string, ResourceCacheEntry>;
|
||||
|
||||
export type SidebarItemMap = Map<string, SidebarDocLike & { docId: string }>;
|
||||
|
||||
export type LearningResourceList = LearningCenterResource[];
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
applyLearningCenterFilters,
|
||||
DIFFICULTY_LEVELS,
|
||||
type DifficultyLevel,
|
||||
extractAvailableCategories,
|
||||
extractAvailableDifficulties,
|
||||
type LearningCenterResource,
|
||||
} from "../../utils/learningCenter/utils";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
export interface UseLearningCenterFilterResult {
|
||||
/** Current text filter value (immediate, for input field) */
|
||||
filter: string;
|
||||
/** Debounced filter value (for filtering and highlighting) */
|
||||
debouncedFilter: string;
|
||||
/** Update the text filter */
|
||||
setFilter: (value: string) => void;
|
||||
/** Clear the text filter */
|
||||
clearFilter: () => void;
|
||||
/** Currently selected categories */
|
||||
selectedCategories: string[];
|
||||
/** Toggle a category selection */
|
||||
toggleCategory: (category: string) => void;
|
||||
/** Currently selected difficulty */
|
||||
selectedDifficulty: DifficultyLevel | null;
|
||||
/** Set difficulty filter */
|
||||
setDifficulty: (difficulty: DifficultyLevel | null) => void;
|
||||
/** Resources after applying all filters */
|
||||
filteredResources: LearningCenterResource[];
|
||||
/** All available categories extracted from resources */
|
||||
availableCategories: string[];
|
||||
/** All available difficulty levels extracted from resources */
|
||||
availableDifficulties: DifficultyLevel[];
|
||||
}
|
||||
|
||||
export interface UseLearningCenterFilterOptions {
|
||||
initialFilter?: string;
|
||||
initialCategories?: string[];
|
||||
initialDifficulty?: DifficultyLevel | null;
|
||||
}
|
||||
|
||||
function sanitizeInitialCategories(
|
||||
resources: LearningCenterResource[],
|
||||
initialCategories: string[] | undefined,
|
||||
): string[] {
|
||||
if (!initialCategories || initialCategories.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const availableCategories = new Set(resources.map((resource) => resource.category));
|
||||
return Array.from(
|
||||
new Set(initialCategories.filter((category) => availableCategories.has(category))),
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeInitialDifficulty(
|
||||
initialDifficulty: DifficultyLevel | null | undefined,
|
||||
): DifficultyLevel | null {
|
||||
if (!initialDifficulty) {
|
||||
return null;
|
||||
}
|
||||
return DIFFICULTY_LEVELS.includes(initialDifficulty) ? initialDifficulty : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook that manages learning center filtering state and logic.
|
||||
* Handles text search, category filtering, tag filtering, and difficulty filtering.
|
||||
*/
|
||||
export function useLearningCenterFilter(
|
||||
resources: LearningCenterResource[],
|
||||
options: UseLearningCenterFilterOptions = {},
|
||||
): UseLearningCenterFilterResult {
|
||||
const [filter, setFilterValue] = useState(() => options.initialFilter?.trim() ?? "");
|
||||
const [debouncedFilter, setDebouncedFilter] = useState(
|
||||
() => options.initialFilter?.trim() ?? "",
|
||||
);
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>(() =>
|
||||
sanitizeInitialCategories(resources, options.initialCategories),
|
||||
);
|
||||
const [selectedDifficulty, setSelectedDifficulty] = useState<DifficultyLevel | null>(() =>
|
||||
sanitizeInitialDifficulty(options.initialDifficulty),
|
||||
);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
// Debounce the filter value for performance
|
||||
useEffect(() => {
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setDebouncedFilter(filter);
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [filter]);
|
||||
|
||||
// Apply filters based on current selections
|
||||
const filteredResources = useMemo(() => {
|
||||
return applyLearningCenterFilters(resources, {
|
||||
query: debouncedFilter,
|
||||
selectedCategories,
|
||||
selectedDifficulty,
|
||||
});
|
||||
}, [debouncedFilter, selectedCategories, selectedDifficulty, resources]);
|
||||
|
||||
// Extract all unique values from resources
|
||||
const availableCategories = useMemo(() => extractAvailableCategories(resources), [resources]);
|
||||
const availableDifficulties = useMemo(
|
||||
() => extractAvailableDifficulties(resources),
|
||||
[resources],
|
||||
);
|
||||
|
||||
const setFilter = useCallback((value: string) => {
|
||||
setFilterValue(value);
|
||||
}, []);
|
||||
|
||||
const toggleCategory = useCallback((category: string) => {
|
||||
setSelectedCategories((prev) => {
|
||||
if (prev.includes(category)) return prev.filter((c) => c !== category);
|
||||
return [...prev, category];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setDifficulty = useCallback((difficulty: DifficultyLevel | null) => {
|
||||
setSelectedDifficulty(difficulty);
|
||||
}, []);
|
||||
|
||||
const clearFilter = useCallback(() => setFilterValue(""), []);
|
||||
|
||||
return {
|
||||
filter,
|
||||
debouncedFilter,
|
||||
setFilter,
|
||||
clearFilter,
|
||||
selectedCategories,
|
||||
toggleCategory,
|
||||
selectedDifficulty,
|
||||
setDifficulty,
|
||||
filteredResources,
|
||||
availableCategories,
|
||||
availableDifficulties,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
.learningCenterContent {
|
||||
margin-bottom: 2.5rem;
|
||||
padding: 1.5rem;
|
||||
background: linear-gradient(135deg, var(--ifm-color-emphasis-100) 0%, transparent 100%);
|
||||
border-radius: 12px;
|
||||
border-left: 4px solid var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.difficultyBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.beginner {
|
||||
background-color: var(--lc-difficulty-beginner);
|
||||
}
|
||||
|
||||
.intermediate {
|
||||
background-color: var(--lc-difficulty-intermediate);
|
||||
}
|
||||
|
||||
.advanced {
|
||||
background-color: var(--lc-difficulty-advanced);
|
||||
}
|
||||
|
||||
.timeBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
background-color: var(--ifm-color-emphasis-200);
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
|
||||
.shortDescription {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.7;
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
margin: 0 0 1.25rem 0;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
.tagChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background-color: var(--ifm-color-emphasis-200);
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.tagChip:hover {
|
||||
background-color: var(--ifm-color-emphasis-300);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Renders learning center resource frontmatter as nicely formatted content.
|
||||
*/
|
||||
|
||||
import { type DifficultyLevel, getDifficultyLabel } from "../../utils/learningCenter/utils";
|
||||
import styles from "./LearningCenterContent.module.css";
|
||||
|
||||
import React from "react";
|
||||
|
||||
export interface LearningCenterContentProps {
|
||||
learningPaths?: string[];
|
||||
shortDescription: string;
|
||||
difficulty: DifficultyLevel;
|
||||
estimatedTime?: string;
|
||||
}
|
||||
|
||||
const ClockIcon: React.FC = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ marginRight: "0.25rem" }}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const LearningCenterContent: React.FC<LearningCenterContentProps> = ({
|
||||
learningPaths,
|
||||
shortDescription,
|
||||
difficulty,
|
||||
estimatedTime,
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.learningCenterContent}>
|
||||
<div className={styles.meta}>
|
||||
<span className={`${styles.difficultyBadge} ${styles[difficulty]}`}>
|
||||
{getDifficultyLabel(difficulty)}
|
||||
</span>
|
||||
{estimatedTime ? (
|
||||
<span className={styles.timeBadge}>
|
||||
<ClockIcon />
|
||||
{estimatedTime}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className={styles.shortDescription}>{shortDescription}</p>
|
||||
|
||||
{learningPaths && learningPaths.length > 0 ? (
|
||||
<div className={styles.tags}>
|
||||
{learningPaths.map((learningPath) => (
|
||||
<span key={learningPath} className={styles.tagChip}>
|
||||
{learningPath}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LearningCenterContent;
|
||||
@@ -14,7 +14,13 @@ import { SupportBadge } from "#components/SupportBadge.tsx";
|
||||
import { VersionBadge } from "#components/VersionBadge.tsx";
|
||||
|
||||
import { useSyntheticTitle } from "#hooks/title.ts";
|
||||
import { LearningCenterContent } from "#theme/DocItem/Content/LearningCenterContent.tsx";
|
||||
import { PreReleaseAdmonition } from "#theme/DocItem/Content/PreReleaseAdmonition.tsx";
|
||||
import {
|
||||
extractLearningPathsFromProps,
|
||||
safeDifficultyExtract,
|
||||
safeStringExtract,
|
||||
} from "#theme/utils/learningCenter/utils.ts";
|
||||
|
||||
import { useDoc } from "@docusaurus/plugin-content-docs/client";
|
||||
import { ThemeClassNames } from "@docusaurus/theme-common";
|
||||
@@ -78,6 +84,15 @@ const BadgeGroup: React.FC<BadgesProps> = ({ badges }) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the current page is a learning center resource page
|
||||
*/
|
||||
function isLearningCenterPage(docId: string): boolean {
|
||||
// Doc IDs can be like "core/learning-center/category-a/article-a"
|
||||
// But not the index page
|
||||
return /learning-center\/[^/]+\/[^/]+/.test(docId) && !docId.endsWith("/index");
|
||||
}
|
||||
|
||||
const DocItemContent: React.FC<Props> = ({ children }) => {
|
||||
const syntheticTitle = useSyntheticTitle();
|
||||
const { frontMatter, metadata, contentTitle } = useDoc();
|
||||
@@ -88,8 +103,23 @@ const DocItemContent: React.FC<Props> = ({ children }) => {
|
||||
authentik_version,
|
||||
authentik_enterprise,
|
||||
authentik_preview,
|
||||
sidebar_custom_props,
|
||||
} = frontMatter;
|
||||
|
||||
// Extract learning center resource data from sidebar_custom_props
|
||||
const customProps = sidebar_custom_props as Record<string, unknown> | undefined;
|
||||
const isLearningCenter = isLearningCenterPage(id);
|
||||
|
||||
const learningCenterData =
|
||||
isLearningCenter && customProps
|
||||
? {
|
||||
learningPaths: extractLearningPathsFromProps(customProps),
|
||||
shortDescription: safeStringExtract(customProps.shortDescription),
|
||||
difficulty: safeDifficultyExtract(customProps.difficulty),
|
||||
estimatedTime: safeStringExtract(customProps.estimatedTime),
|
||||
}
|
||||
: null;
|
||||
|
||||
const preReleaseDoc = frontMatter.beta && metadata.id.startsWith("releases");
|
||||
|
||||
useBadgeLinterEffect();
|
||||
@@ -136,6 +166,15 @@ const DocItemContent: React.FC<Props> = ({ children }) => {
|
||||
|
||||
{preReleaseDoc ? <PreReleaseAdmonition /> : null}
|
||||
|
||||
{learningCenterData && learningCenterData.shortDescription ? (
|
||||
<LearningCenterContent
|
||||
learningPaths={learningCenterData.learningPaths}
|
||||
shortDescription={learningCenterData.shortDescription}
|
||||
difficulty={learningCenterData.difficulty}
|
||||
estimatedTime={learningCenterData.estimatedTime}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<MDXContent>{children}</MDXContent>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "./styles.css";
|
||||
|
||||
import { isGlossaryItem } from "../utils/glossaryUtils";
|
||||
import { shouldFilterFromSidebar as shouldFilterLearningCenterItem } from "../utils/learningCenter/utils";
|
||||
|
||||
import { VersionPicker } from "#components/VersionPicker/index.tsx";
|
||||
|
||||
@@ -17,6 +18,16 @@ function isReleaseNotesItem(item: PropSidebarItem): boolean {
|
||||
return !!(item.type === "link" && item.docId?.startsWith("releases"));
|
||||
}
|
||||
|
||||
function getSidebarItemKey(item: PropSidebarItem, fallbackIndex: number): string {
|
||||
if (item.type === "link") {
|
||||
return item.docId || item.href || item.label || String(fallbackIndex);
|
||||
}
|
||||
if (item.type === "category") {
|
||||
return item.label || item.href || String(fallbackIndex);
|
||||
}
|
||||
return String(fallbackIndex);
|
||||
}
|
||||
|
||||
function useVisibleSidebarItems(
|
||||
items: readonly PropSidebarItem[],
|
||||
activePath: string,
|
||||
@@ -32,18 +43,28 @@ function useVisibleSidebarItems(
|
||||
|
||||
const DocSidebarItems = ({ items, ...props }: DocSidebarItemsProps): JSX.Element => {
|
||||
const visibleItems = useVisibleSidebarItems(items, props.activePath);
|
||||
const navigableItems = useMemo(
|
||||
() =>
|
||||
visibleItems.filter(
|
||||
(item) => !isGlossaryItem(item) && !shouldFilterLearningCenterItem(item),
|
||||
),
|
||||
[visibleItems],
|
||||
);
|
||||
|
||||
const includeVersionPicker = props.level === 1 && !props.activePath.startsWith("/integrations");
|
||||
|
||||
return (
|
||||
<DocSidebarItemsExpandedStateProvider>
|
||||
{includeVersionPicker ? <VersionPicker /> : null}
|
||||
{visibleItems.map((item, index) => {
|
||||
if (isGlossaryItem(item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <DocSidebarItem key={index} item={item} index={index} {...props} />;
|
||||
{navigableItems.map((item, index) => {
|
||||
return (
|
||||
<DocSidebarItem
|
||||
key={getSidebarItemKey(item, index)}
|
||||
item={item}
|
||||
index={index}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DocSidebarItemsExpandedStateProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Category descriptions loaded from _category_.json files.
|
||||
* Import category metadata and export descriptions for use in components.
|
||||
*
|
||||
* Keep this list in sync when adding new learning-center categories.
|
||||
*/
|
||||
|
||||
import customizeYourInstance from "../../../../docs/core/learning-center/customize-your-instance/_category_.json";
|
||||
|
||||
export interface CategoryMetadata {
|
||||
label: string;
|
||||
position: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of category names to their metadata from _category_.json files.
|
||||
* Add new categories here as they are created.
|
||||
*/
|
||||
const categoryMetadata: Record<string, CategoryMetadata> = Object.fromEntries(
|
||||
[customizeYourInstance].map((category) => {
|
||||
const metadata = category as CategoryMetadata;
|
||||
return [metadata.label, metadata];
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the description for a category by its slug.
|
||||
* Returns a fallback message if no description is defined.
|
||||
*/
|
||||
export function getCategoryDescription(categorySlug: string): string {
|
||||
const metadata = categoryMetadata[categorySlug];
|
||||
if (metadata?.description) {
|
||||
return metadata.description;
|
||||
}
|
||||
// Fallback for categories without descriptions
|
||||
const label = metadata?.label || categorySlug.replace(/[-_]/g, " ");
|
||||
return `Resources in the ${label} category.`;
|
||||
}
|
||||
347
website/docusaurus-theme/theme/utils/learningCenter/utils.ts
Normal file
347
website/docusaurus-theme/theme/utils/learningCenter/utils.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import type { PropSidebarItem } from "@docusaurus/plugin-content-docs";
|
||||
|
||||
/**
|
||||
* Difficulty levels for learning resources
|
||||
*/
|
||||
export const DIFFICULTY_LEVELS = ["beginner", "intermediate", "advanced"] as const;
|
||||
export type DifficultyLevel = (typeof DIFFICULTY_LEVELS)[number];
|
||||
|
||||
/**
|
||||
* Types of learning resources
|
||||
*/
|
||||
export const RESOURCE_TYPES = ["tutorial", "guide", "reference", "video", "example"] as const;
|
||||
export type ResourceType = (typeof RESOURCE_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Standardized resource interface used by LearningCenterHelper and DocCardList
|
||||
*/
|
||||
export interface LearningCenterResource {
|
||||
id: string;
|
||||
resourceName: string;
|
||||
category: string;
|
||||
learningPaths: string[];
|
||||
shortDescription: string;
|
||||
longDescription?: string;
|
||||
difficulty: DifficultyLevel;
|
||||
resourceType: ResourceType;
|
||||
estimatedTime?: string;
|
||||
prerequisites?: string[];
|
||||
relatedResources?: string[];
|
||||
}
|
||||
|
||||
export interface LearningCenterFilterCriteria {
|
||||
query?: string;
|
||||
selectedCategories?: readonly string[];
|
||||
selectedDifficulty?: DifficultyLevel | null;
|
||||
selectedLearningPath?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the current path is within the learning center section
|
||||
*/
|
||||
export function isLearningCenterPath(pathname: string): boolean {
|
||||
return /\/learning-center(\/|$)/.test(pathname);
|
||||
}
|
||||
|
||||
function isLearningCenterIndexDocId(docId: string): boolean {
|
||||
return docId.endsWith("learning-center/index") || docId === "core/learning-center/index";
|
||||
}
|
||||
|
||||
function isLearningCenterPathDocId(docId: string): boolean {
|
||||
return docId.includes("/learning-center/path/") || docId.includes("/learning-center/paths/");
|
||||
}
|
||||
|
||||
function isLearningCenterArticlesDocId(docId: string): boolean {
|
||||
return (
|
||||
docId.endsWith("learning-center/articles") ||
|
||||
docId.endsWith("learning-center/articles/index")
|
||||
);
|
||||
}
|
||||
|
||||
function isLearningCenterIndexHref(href: string): boolean {
|
||||
return href.endsWith("/learning-center") || href.endsWith("/learning-center/");
|
||||
}
|
||||
|
||||
function isLearningCenterPathHref(href: string): boolean {
|
||||
return href.includes("/learning-center/path/") || href.includes("/learning-center/paths/");
|
||||
}
|
||||
|
||||
function isLearningCenterArticlesHref(href: string): boolean {
|
||||
return (
|
||||
href.endsWith("/learning-center/articles") || href.endsWith("/learning-center/articles/")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a sidebar item is a learning center article (leaf item only).
|
||||
* This is used for resource extraction in DocCardList.
|
||||
*/
|
||||
export function isLearningCenterItem(item: PropSidebarItem): boolean {
|
||||
// Only check link items
|
||||
if (item.type !== "link") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's a link with docId containing 'learning-center' (but not the index)
|
||||
if ("docId" in item) {
|
||||
const docId = item.docId || "";
|
||||
if (
|
||||
docId.includes("learning-center") &&
|
||||
!isLearningCenterIndexDocId(docId) &&
|
||||
!isLearningCenterPathDocId(docId) &&
|
||||
!isLearningCenterArticlesDocId(docId)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a link with href containing '/learning-center/' (but not the index)
|
||||
if ("href" in item) {
|
||||
const href = item.href || "";
|
||||
if (
|
||||
href.includes("/learning-center/") &&
|
||||
!isLearningCenterPathHref(href) &&
|
||||
!isLearningCenterIndexHref(href) &&
|
||||
!isLearningCenterArticlesHref(href)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a category is the top-level Learning Center category (links to index)
|
||||
*/
|
||||
function isLearningCenterMainCategory(item: PropSidebarItem): boolean {
|
||||
if (item.type !== "category") return false;
|
||||
|
||||
// Check if this category has a link to the learning center index
|
||||
if ("link" in item && item.link) {
|
||||
const link = item.link as { type?: string; id?: string };
|
||||
if (link.type === "doc" && link.id) {
|
||||
// Check if the link ID ends with learning-center/index
|
||||
if (isLearningCenterIndexDocId(link.id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check if the label is "Learning Center" (from autogenerated)
|
||||
if ("label" in item && item.label === "Learning Center") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a sidebar item should be filtered from the sidebar navigation.
|
||||
* This filters:
|
||||
* - Learning center article links (leaf items)
|
||||
* - Learning center sub-categories (category-a, category-b, etc.)
|
||||
* But NOT the main Learning Center link/category itself.
|
||||
*/
|
||||
export function shouldFilterFromSidebar(item: PropSidebarItem): boolean {
|
||||
// Never filter the main Learning Center category (the one with index link)
|
||||
if (isLearningCenterMainCategory(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter learning center article links
|
||||
if (item.type === "link") {
|
||||
if ("docId" in item) {
|
||||
const docId = item.docId || "";
|
||||
// Filter articles within learning-center sub-directories (e.g., learning-center/category-a/article-a)
|
||||
// but NOT the index (learning-center/index)
|
||||
if (docId.includes("learning-center/") && !isLearningCenterIndexDocId(docId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if ("href" in item) {
|
||||
const href = item.href || "";
|
||||
// Filter links that go deeper into learning-center (e.g., /learning-center/category-a/...)
|
||||
const pathParts = href.split("/").filter(Boolean);
|
||||
const lcIndex = pathParts.indexOf("learning-center");
|
||||
// If there are parts after "learning-center" (sub-paths), filter it
|
||||
if (lcIndex >= 0 && pathParts.length > lcIndex + 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter learning center sub-categories (category-a, category-b, etc.)
|
||||
if (item.type === "category" && "items" in item && Array.isArray(item.items)) {
|
||||
// Check if ALL children are learning center items - if so, this is a LC sub-category
|
||||
const hasOnlyLcItems =
|
||||
item.items.length > 0 &&
|
||||
item.items.every((child) => {
|
||||
if (child.type === "link") {
|
||||
return shouldFilterFromSidebar(child);
|
||||
}
|
||||
if (child.type === "category") {
|
||||
return shouldFilterFromSidebar(child);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (hasOnlyLcItems) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts string value from potentially undefined/mixed-type object property
|
||||
*/
|
||||
export function safeStringExtract(value: unknown, fallback: string = ""): string {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts string array from potentially undefined/mixed-type object property
|
||||
*/
|
||||
export function safeStringArrayExtract(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((tag): tag is string => typeof tag === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts learning path values from metadata.
|
||||
*/
|
||||
export function extractLearningPathsFromProps(
|
||||
props: Record<string, unknown> | undefined,
|
||||
): string[] {
|
||||
if (!props) {
|
||||
return [];
|
||||
}
|
||||
return safeStringArrayExtract(props.learningPaths);
|
||||
}
|
||||
|
||||
function isDifficultyLevel(value: unknown): value is DifficultyLevel {
|
||||
return typeof value === "string" && DIFFICULTY_LEVELS.includes(value as DifficultyLevel);
|
||||
}
|
||||
|
||||
function isResourceType(value: unknown): value is ResourceType {
|
||||
return typeof value === "string" && RESOURCE_TYPES.includes(value as ResourceType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts difficulty level from potentially undefined/mixed-type object property
|
||||
*/
|
||||
export function safeDifficultyExtract(value: unknown): DifficultyLevel {
|
||||
if (isDifficultyLevel(value)) {
|
||||
return value;
|
||||
}
|
||||
return "beginner";
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts resource type from potentially undefined/mixed-type object property
|
||||
*/
|
||||
export function safeResourceTypeExtract(value: unknown): ResourceType {
|
||||
if (isResourceType(value)) {
|
||||
return value;
|
||||
}
|
||||
return "tutorial";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all available categories from a collection of resources.
|
||||
*/
|
||||
export const extractAvailableCategories = (
|
||||
resources: readonly LearningCenterResource[],
|
||||
): string[] => Array.from(new Set(resources.map((r) => r.category))).toSorted();
|
||||
|
||||
/**
|
||||
* Extracts all available difficulty levels from a collection of resources.
|
||||
*/
|
||||
export const extractAvailableDifficulties = (
|
||||
resources: readonly LearningCenterResource[],
|
||||
): DifficultyLevel[] => {
|
||||
const available = new Set(resources.map((r) => r.difficulty));
|
||||
return DIFFICULTY_LEVELS.filter((difficulty) => available.has(difficulty));
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts all available resource types from a collection of resources.
|
||||
*/
|
||||
export const extractAvailableResourceTypes = (
|
||||
resources: readonly LearningCenterResource[],
|
||||
): ResourceType[] => {
|
||||
const available = new Set(resources.map((r) => r.resourceType));
|
||||
return RESOURCE_TYPES.filter((type) => available.has(type));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a human-readable label for difficulty level
|
||||
*/
|
||||
export function getDifficultyLabel(difficulty: DifficultyLevel): string {
|
||||
const labels: Record<DifficultyLevel, string> = {
|
||||
beginner: "Beginner",
|
||||
intermediate: "Intermediate",
|
||||
advanced: "Advanced",
|
||||
};
|
||||
return labels[difficulty];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable label for resource type
|
||||
*/
|
||||
export function getResourceTypeLabel(type: ResourceType): string {
|
||||
const labels: Record<ResourceType, string> = {
|
||||
tutorial: "Tutorial",
|
||||
guide: "Guide",
|
||||
reference: "Reference",
|
||||
video: "Video",
|
||||
example: "Example",
|
||||
};
|
||||
return labels[type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies all learning center filters in a pure, testable way.
|
||||
*/
|
||||
export function applyLearningCenterFilters(
|
||||
resources: readonly LearningCenterResource[],
|
||||
criteria: LearningCenterFilterCriteria,
|
||||
): LearningCenterResource[] {
|
||||
const {
|
||||
query = "",
|
||||
selectedCategories = [],
|
||||
selectedDifficulty = null,
|
||||
selectedLearningPath = null,
|
||||
} = criteria;
|
||||
|
||||
let result = [...resources];
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
|
||||
if (normalizedQuery) {
|
||||
result = result.filter(
|
||||
(resource) =>
|
||||
resource.resourceName.toLowerCase().includes(normalizedQuery) ||
|
||||
resource.shortDescription.toLowerCase().includes(normalizedQuery) ||
|
||||
(resource.longDescription &&
|
||||
resource.longDescription.toLowerCase().includes(normalizedQuery)) ||
|
||||
resource.category.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedCategories.length > 0) {
|
||||
result = result.filter((resource) => selectedCategories.includes(resource.category));
|
||||
}
|
||||
|
||||
if (selectedDifficulty) {
|
||||
result = result.filter((resource) => resource.difficulty === selectedDifficulty);
|
||||
}
|
||||
|
||||
if (selectedLearningPath) {
|
||||
result = result.filter((resource) => resource.learningPaths.includes(selectedLearningPath));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -3,6 +3,21 @@ import * as fa7Solid from "@iconify-json/fa7-solid";
|
||||
import * as mdi from "@iconify-json/mdi";
|
||||
import mermaid from "mermaid";
|
||||
|
||||
function ensureGoogleAnalyticsStub() {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {Window & { ga?: (...args: unknown[]) => unknown }} */
|
||||
const windowWithGa = window;
|
||||
|
||||
if (typeof windowWithGa.ga !== "function") {
|
||||
windowWithGa.ga = () => undefined;
|
||||
}
|
||||
}
|
||||
|
||||
ensureGoogleAnalyticsStub();
|
||||
|
||||
mermaid.registerIconPacks([
|
||||
{
|
||||
name: fa7Regular.icons.prefix,
|
||||
@@ -17,3 +32,7 @@ mermaid.registerIconPacks([
|
||||
icons: mdi.icons,
|
||||
},
|
||||
]);
|
||||
|
||||
export function onRouteDidUpdate() {
|
||||
ensureGoogleAnalyticsStub();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user