mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-07 23:52:04 +02:00
Compare commits
9 Commits
refacto/re
...
feature/cu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab74aeff5c | ||
|
|
482975c2b5 | ||
|
|
b4afee1b8c | ||
|
|
a1cb4cee95 | ||
|
|
17b668485d | ||
|
|
5ce23661e6 | ||
|
|
bc360f31fc | ||
|
|
a174a00af8 | ||
|
|
f0f2ca5edd |
@@ -99,6 +99,8 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
|
||||
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
|
||||
| PLUGINS_CONFIG_FILE_PATH | Full path to the JSON file containing the plugins configuration loaded by the backend. Example: src/backend/impress/configuration/plugins/default.json | BASE_DIR/impress/configuration/plugins/default.json |
|
||||
| PLUGINS_CONFIG_CACHE_TIMEOUT | Time in seconds the plugins configuration file is cached by the backend before being reloaded. Default is 2 hours (7200 seconds). | 7200 |
|
||||
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
|
||||
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
|
||||
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
|
||||
|
||||
589
docs/frontend-plugins.md
Normal file
589
docs/frontend-plugins.md
Normal file
@@ -0,0 +1,589 @@
|
||||
# Frontend Plugin System
|
||||
|
||||
## Table of Contents
|
||||
- [Overview](#overview "Go to Overview section")
|
||||
- [Getting Started: Building Your First Plugin](#getting-started-building-your-first-plugin "Go to the Getting Started guide")
|
||||
- [1. Prepare the Host Environment](#1-prepare-the-host-environment "Step 1: Prepare the host")
|
||||
- [2. Scaffolding a New Plugin Project](#2-scaffolding-a-new-plugin-project "Step 2: Scaffold the plugin")
|
||||
- [3. Creating a Plugin Component](#3-creating-a-plugin-component "Step 3: Create the React component")
|
||||
- [4. Federation Configuration](#4-federation-configuration "Step 4: Configure module federation")
|
||||
- [5. Enabling Type-Sharing for Intellisense](#5-enabling-type-sharing-for-intellisense "Step 5: Enable type-sharing")
|
||||
- [6. Running and Configuring Your Plugin](#6-running-and-configuring-your-plugin "Step 6: Run and configure")
|
||||
- [Host-Plugin Interaction](#host-plugin-interaction "How the host and plugin interact")
|
||||
- [Host Exports](#host-exports "What the host exports")
|
||||
- [Choosing Shared Dependencies](#choosing-shared-dependencies "Learn about shared dependencies")
|
||||
- [Development Workflow](#development-workflow "Go to Development Workflow section")
|
||||
- [Test and Debug](#test-and-debug "How to test and debug")
|
||||
- [Best Practices](#best-practices "View best practices")
|
||||
- [Plugin Configuration File Reference](#plugin-configuration-file-reference "Go to the Configuration File reference")
|
||||
- [Configuration Structure](#configuration-structure "See the config file structure")
|
||||
- [Injection Position Examples](#injection-position-examples "See examples of injection positions")
|
||||
- [Releasing a Plugin](#releasing-a-plugin "How to release a plugin")
|
||||
- [Deploying Docs with Plugins](#deploying-docs-with-plugins "How to deploy plugins in production")
|
||||
|
||||
## Overview
|
||||
|
||||
The plugin system allows developers to extend the application's functionality and appearance without modifying the core.
|
||||
It's ideal for teams or third parties to add custom features.
|
||||
|
||||
<br>
|
||||
|
||||
### Glossary
|
||||
- **Remote**: An application exposing components via module federation.
|
||||
- **Host**: The main entry point application. This is Docs itself ("impress").
|
||||
- **Plugin**: A remote module integrated into the host to provide UI components.
|
||||
- **Module Federation**: The technology that enables runtime module sharing between separate applications.
|
||||
|
||||
<br>
|
||||
|
||||
### Features and Limitations
|
||||
**Features:**
|
||||
- Add new UI components.
|
||||
- Reuse host UI components.
|
||||
- Dynamically inject components via CSS selectors and a [configuration file](#plugin-configuration-file-reference "See the configuration file reference").
|
||||
- Integrate without rebuilding or redeploying the host application.
|
||||
- Build and version plugins independently.
|
||||
|
||||
<br>
|
||||
|
||||
**Limitations:**
|
||||
- Focused on DOM/UI customisations; you cannot add Next.js routes or other server-side features.
|
||||
- Runs client-side without direct host state access. <br>
|
||||
Shared caches (e.g., React Query) only work if the dependency is also [shared as a singleton](#choosing-shared-dependencies "Learn about shared dependencies").
|
||||
- Host upgrades may require tweaking CSS selectors <br>
|
||||
and matching versions for shared libraries.
|
||||
|
||||
<br>
|
||||
|
||||
## Getting Started: Building Your First Plugin
|
||||
|
||||
A plugin is a standalone React application bundled with Webpack <br>
|
||||
that exposes one or more components via [Module Federation](#4-federation-configuration "See the federation configuration").
|
||||
This guide walks you through creating your first plugin.
|
||||
|
||||
<br>
|
||||
|
||||
### 1. Prepare the Host Environment
|
||||
|
||||
Developing a plugin requires running the host application (Docs) in parallel.
|
||||
This live integration is essential for rendering your plugin, enabling hot-reloading, sharing types for Intellisense, <br>
|
||||
and discovering the exact versions of [shared dependencies](#choosing-shared-dependencies "Learn about shared dependencies").
|
||||
|
||||
<br>
|
||||
|
||||
1. **Clone the repository locally**:<br>
|
||||
If you haven't already, clone the Docs repository to your local machine and follow the initial setup instructions.
|
||||
2. **Set the development flag**:<br>
|
||||
In the host application's `.env.development` file, set `NEXT_PUBLIC_DEVELOP_PLUGINS=true`.
|
||||
3. **Stop conflicting services**:<br>
|
||||
If you are using the project's Docker setup, make sure <br>
|
||||
the frontend service is stopped (`docker compose stop frontend-development`), as we will run the Docs frontend locally.
|
||||
4. **Run the host**:<br>
|
||||
Navigate to `src/frontend/apps/impress`, run `yarn install`, and then `yarn dev`.
|
||||
5. **Check the logs**:<br>
|
||||
On startup, the Next.js dev server will print the versions of all shared singleton libraries (e.g., React, styled-components). <br>
|
||||
You will need these exact versions for your plugin's `package.json`.
|
||||
|
||||
<br>
|
||||
|
||||
### 2. Scaffolding a New Plugin Project
|
||||
|
||||
Create a new, simple React project. <br>
|
||||
Your project should have a [`webpack.config.js`](#4-federation-configuration "See the federation configuration") and include dependencies for React, Webpack, and TypeScript.
|
||||
|
||||
<br>
|
||||
|
||||
A minimal `package.json` would look like this:<br>
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "webpack serve --mode=development",
|
||||
"build": "webpack --mode=production"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "<same as host>",
|
||||
"react-dom": "<same as host>",
|
||||
"styled-components": "<same as host>",
|
||||
"@openfun/cunningham-react": "<same as host>",
|
||||
"@tanstack/react-query": "<same as host>"
|
||||
},
|
||||
"devDependencies": {
|
||||
"webpack": "^5.0.0",
|
||||
"webpack-cli": "^5.0.0",
|
||||
"webpack-dev-server": "^4.0.0",
|
||||
"ts-loader": "^9.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"@types/react": "^18.0.0",
|
||||
"@module-federation/native-federation-typescript": "^0.2.1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Replace `<same as host>` with the versions found in the [host's dev startup log](#1-prepare-the-host-environment "See how to prepare the host").
|
||||
|
||||
<br>
|
||||
|
||||
### 3\. Creating a Plugin Component
|
||||
|
||||
This is a React component that your `webpack.config.js` file exposes. <br>
|
||||
This minimal example shows how to accept `props`, which can be passed from the [plugin configuration file](#plugin-configuration-file-reference "See the configuration file reference").
|
||||
|
||||
<br>
|
||||
|
||||
```typescript
|
||||
// src/MyCustomComponent.tsx
|
||||
import React from 'react';
|
||||
|
||||
// A simple component with inline prop types
|
||||
const MyCustomComponent = ({ message }: { message?: string }) => {
|
||||
return (
|
||||
<div>
|
||||
This is the plugin component.
|
||||
{message && <p>Message from props: {message}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyCustomComponent;
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
### 4\. Federation Configuration
|
||||
|
||||
The core of the plugin is its Webpack configuration. <br>
|
||||
All plugins should use this `webpack.config.js` as a base.
|
||||
|
||||
Disclaimer:
|
||||
We try to not change this file.<br>
|
||||
But in the future, it may evolve as the plugin system matures.
|
||||
|
||||
<br>
|
||||
|
||||
```javascript
|
||||
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
|
||||
const {
|
||||
NativeFederationTypeScriptHost,
|
||||
} = require('@module-federation/native-federation-typescript/webpack');
|
||||
const {
|
||||
NativeFederationTypeScriptHost: NativeFederationTypeScriptHostCore,
|
||||
} = require('@module-federation/native-federation-typescript');
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const dev = argv.mode !== 'production';
|
||||
|
||||
const moduleFederationConfig = {
|
||||
name: 'my_plugin', // A unique name for your plugin
|
||||
filename: 'remoteEntry.js',
|
||||
exposes: {
|
||||
// Maps a public name to a component file
|
||||
'./MyCustomComponent': './src/MyCustomComponent.tsx',
|
||||
},
|
||||
remotes: {
|
||||
// Allows importing from the host application. The URL is switched automatically.
|
||||
impress: dev
|
||||
? 'impress@http://localhost:3000/_next/static/chunks/remoteEntry.js' // Development
|
||||
: 'impress@/_next/static/chunks/remoteEntry.js', // Production
|
||||
},
|
||||
shared: {
|
||||
// Defines shared libraries to avoid duplication
|
||||
react: { singleton: true },
|
||||
'react-dom': { singleton: true },
|
||||
'styled-components': { singleton: true },
|
||||
'@openfun/cunningham-react': { singleton: true },
|
||||
'@tanstack/react-query': { singleton: true },
|
||||
},
|
||||
};
|
||||
|
||||
let mfTypesReady;
|
||||
const ensureFederatedTypesPlugin = {
|
||||
apply(compiler) {
|
||||
compiler.hooks.beforeCompile.tapPromise(
|
||||
'EnsureFederatedTypes',
|
||||
async () => {
|
||||
if (!mfTypesReady) {
|
||||
const downloader = NativeFederationTypeScriptHostCore.raw({
|
||||
moduleFederationConfig,
|
||||
});
|
||||
mfTypesReady = downloader.writeBundle();
|
||||
}
|
||||
await mfTypesReady;
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
devServer: {
|
||||
// The port should match the one in your plugin's configuration file
|
||||
port: 8080,
|
||||
},
|
||||
entry: './src/index.tsx', // Your plugin's entry point; can be an empty file as modules are exposed directly.
|
||||
plugins: [
|
||||
new ModuleFederationPlugin(moduleFederationConfig),
|
||||
// This plugin enables type-sharing for intellisense
|
||||
...(dev
|
||||
? [
|
||||
ensureFederatedTypesPlugin, // ensures the zip is ready before the first compile
|
||||
NativeFederationTypeScriptHost({ moduleFederationConfig }),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
// ... other webpack config (output, module rules, etc.)
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
> Don't change `remotes.impress` if you want your [released plugin](#releasing-a-plugin "Learn how to release a plugin") to be [deployable by others](#deploying-docs-with-plugins "Learn about deployment").
|
||||
|
||||
<br>
|
||||
|
||||
### 5\. Enabling Type-Sharing for Intellisense
|
||||
|
||||
To get autocompletion for components and hooks exposed by the host, <br>
|
||||
configure your plugin's `tsconfig.json` to find the host's types.
|
||||
|
||||
<br>
|
||||
|
||||
In your plugin's `tsconfig.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"*": ["./@mf-types/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
When you run the host application with `NEXT_PUBLIC_DEVELOP_PLUGINS=true`, it generates a `@mf-types.zip` file. <br>
|
||||
The `NativeFederationTypeScriptHost` plugin in your webpack config will automatically download and unpack it ahead of the first compile, <br>
|
||||
making the host's types available to your plugin and IDE. In development with this flag enabled, route changes may be slower because type generation and automatic exposure run during rebuilds; this does not affect production where navigations are instant.
|
||||
|
||||
<br>
|
||||
|
||||
### 6\. Running and Configuring Your Plugin
|
||||
|
||||
With the host application already running (from step 1), <br>
|
||||
you can now start your plugin's development server and configure the host to load it.
|
||||
|
||||
<br>
|
||||
|
||||
1. **Start the plugin**:<br>
|
||||
In your plugin's project directory, run `yarn dev`.
|
||||
2. **Configure the host**:<br>
|
||||
Tell the host to load your plugin by editing its configuration file. <br>
|
||||
When running Docs locally, this file is located at `src/backend/impress/configuration/plugins/default.json`. <br>
|
||||
Update it to point to your local plugin's `remoteEntry.js`.
|
||||
|
||||
<br>
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-custom-component",
|
||||
"remote": {
|
||||
"url": "http://localhost:8080/remoteEntry.js",
|
||||
"name": "my_plugin",
|
||||
"module": "./MyCustomComponent"
|
||||
},
|
||||
"injection": {
|
||||
"target": "#some-element-in-the-host"
|
||||
},
|
||||
"props": {
|
||||
"message": "Hello from the configuration!"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
After changing the `target` to a valid CSS selector in the host's DOM, save the file. <br>
|
||||
The host application will automatically detect the change and inject your component, passing the `props` object along.
|
||||
|
||||
Your component should appear in the running host application after a reload.
|
||||
|
||||
<br>
|
||||
|
||||
## Host-Plugin Interaction
|
||||
|
||||
### Host Exports
|
||||
|
||||
The host automatically exposes many of its components and hooks. <br>
|
||||
You can import them in the plugin as if they were local modules, thanks to the [`remotes` configuration](#4-federation-configuration "See the remotes config in Webpack") in the `webpack.config.js`.
|
||||
|
||||
<br>
|
||||
|
||||
```typescript
|
||||
// In the plugin's code
|
||||
import { Icon } from 'impress/components';
|
||||
import { useAuthQuery } from 'impress/features/auth/api';
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
### Choosing Shared Dependencies
|
||||
|
||||
Sharing dependencies is critical for performance and stability.
|
||||
|
||||
<br>
|
||||
|
||||
- **Minimal Shared Libraries**:<br>
|
||||
Always share **`react`**, **`react-dom`**, **`styled-components`**, and **`@openfun/cunningham-react`** to use the same instances as the host.
|
||||
- **Sharing State**:<br>
|
||||
Libraries that rely on a global context (like `@tanstack/react-query`) **must** be shared to access the host's state and cache.
|
||||
- **Discovering More Shared Libraries**: With `NEXT_PUBLIC_DEVELOP_PLUGINS=true`, <br>
|
||||
[the host prints its shared dependency map to the Next.js dev server logs on startup](#1-prepare-the-host-environment "See how to prepare the host"). <br>
|
||||
You can use this to align versions and add more shared libraries to your plugin.
|
||||
|
||||
<br>
|
||||
|
||||
> **Important**: Both the host and the plugin must declare a dependency in [`moduleFederationConfig.shared`](#4-federation-configuration "See the federation configuration") for it to become a true singleton. <br>
|
||||
> If a shared dependency is omitted from the plugin's config, Webpack will bundle a separate copy, breaking the singleton pattern.
|
||||
|
||||
<br>
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Test and Debug
|
||||
|
||||
- Use the `[PluginSystem]` logs in the browser console to see if the plugin is loading correctly.
|
||||
- Errors in the plugin are caught by an `ErrorBoundary` and will not crash the host.
|
||||
|
||||
<br>
|
||||
|
||||
Common Errors:
|
||||
| Issue | Cause/Fix |
|
||||
| :--- | :--- |
|
||||
| Unreachable `remoteEntry.js` | Check the `url` in the [plugin configuration](#6-running-and-configuring-your-plugin "See how to configure the plugin"). |
|
||||
| Library version conflicts | Ensure `shared` library versions in `package.json` match the [host's versions](#1-prepare-the-host-environment "See how to check host versions"). |
|
||||
| Invalid CSS selectors | Validate the `target` selector against the host's DOM. |
|
||||
|
||||
<br>
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Build modular components with well-typed props.
|
||||
- Prefer using the host's exposed types and components over implementing new ones.
|
||||
- Keep shared dependency versions aligned with the host <br>
|
||||
and re-test after host upgrades.
|
||||
- Treat plugin bundles as untrusted: vet dependencies and avoid unsafe scripts.
|
||||
|
||||
<br>
|
||||
|
||||
## Plugin Configuration File Reference
|
||||
|
||||
This section provides a detailed reference for all fields in the plugin configuration JSON.
|
||||
For deployment details, see [Deploying Docs with Plugins](#deploying-docs-with-plugins "Learn about production deployment").
|
||||
|
||||
<br>
|
||||
|
||||
### Configuration Structure
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `id` | String | Yes | Unique component identifier (e.g., "my-widget"). |
|
||||
| `remote` | Object | Yes | Remote module details. |
|
||||
| - `url` | String | Yes | Path to `remoteEntry.js` (absolute/relative). |
|
||||
| - `name` | String | Yes | Federation remote name (e.g., "myPlugin"). |
|
||||
| - `module` | String | Yes | Exposed module (e.g., "./Widget"). |
|
||||
| `injection`| Object | Yes | Integration control. |
|
||||
| - `target` | String | Yes | CSS selector for insertion point. |
|
||||
| - `position` | String | No (default: "append") | Insertion position (`before`, `after`, `replace`, `prepend`, `append`). See [examples](#injection-position-examples "See injection examples"). |
|
||||
| - `observerRoots` | String/Boolean | No | DOM observation: CSS selector, `true` (observe whole document), or `false` (default; disable observers). |
|
||||
| `props` | Object | No | Props passed to the [plugin component](#3-creating-a-plugin-component "See how to create a component with props"). |
|
||||
| `visibility` | Object | No | Visibility controls. |
|
||||
| - `routes` | Array | No | Path globs (e.g., `["/docs/*", "!/docs/secret*"]`); supports `*` and `?` wildcards plus negation (`!`). |
|
||||
|
||||
<br>
|
||||
|
||||
### Injection Position Examples
|
||||
|
||||
The `injection.position` property controls how the plugin is inserted relative to the `target` element.
|
||||
|
||||
<details>
|
||||
<summary>View injection examples</summary>
|
||||
|
||||
<br>
|
||||
|
||||
**before**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-custom-component-0",
|
||||
"injection": {
|
||||
"target": "#item2",
|
||||
"position": "before"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<ul id="some-element-in-the-host">
|
||||
<li id="item1"></li>
|
||||
<div id="plugin-container-my-custom-component-0"></div>
|
||||
<li id="item2"></li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
**after**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-custom-component-0",
|
||||
"injection": {
|
||||
"target": "#item1",
|
||||
"position": "after"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<ul id="some-element-in-the-host">
|
||||
<li id="item1"></li>
|
||||
<div id="plugin-container-my-custom-component-0"></div>
|
||||
<li id="item2"></li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
**prepend**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-custom-component-0",
|
||||
"injection": {
|
||||
"target": "#some-element-in-the-host",
|
||||
"position": "prepend"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<ul id="some-element-in-the-host">
|
||||
<div id="plugin-container-my-custom-component-0"></div>
|
||||
<li id="item1"></li>
|
||||
<li id="item2"></li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
**append** (default)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-custom-component-0",
|
||||
"injection": {
|
||||
"target": "#some-element-in-the-host",
|
||||
"position": "append"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<ul id="some-element-in-the-host">
|
||||
<li id="item1"></li>
|
||||
<li id="item2"></li>
|
||||
<div id="plugin-container-my-custom-component-0"></div>
|
||||
</ul>
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
**replace**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-custom-component-0",
|
||||
"injection": {
|
||||
"target": "#item1",
|
||||
"position": "replace"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<ul id="some-element-in-the-host">
|
||||
<div id="plugin-container-my-custom-component-0"></div>
|
||||
<li id="item1" data-pluginsystem-hidden="true"></li>
|
||||
<li id="item2"></li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<br>
|
||||
|
||||
## Releasing a Plugin
|
||||
|
||||
When you are ready to release your plugin, you need to create a production build.
|
||||
|
||||
<br>
|
||||
|
||||
Run the build command in your plugin's directory:<br>
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
```
|
||||
|
||||
This command bundles your code for production. <br>
|
||||
Webpack will generate a **`dist`** folder (or similar) containing the **`remoteEntry.js`** file and other JavaScript chunks. <br>
|
||||
The `remoteEntry.js` is the manifest that tells other applications what modules your plugin exposes. <br>
|
||||
These are the files you will need for deployment.
|
||||
|
||||
<br>
|
||||
|
||||
The [`webpack.config.js` provided](#4-federation-configuration "See the federation configuration") is already configured to switch the `remotes` URL to the correct production path automatically, <br>
|
||||
so no code changes are needed before building.
|
||||
|
||||
<br>
|
||||
|
||||
## Deploying Docs with Plugins
|
||||
|
||||
To use plugins in a production environment, you need to deploy both the plugin assets and the configuration file. <br>
|
||||
The recommended approach is to serve the plugin's static files from the same webserver that serves the host (docs frontend).
|
||||
|
||||
<br>
|
||||
|
||||
1. **Deploy Plugin Assets**: <br>
|
||||
Copy the contents of your plugin's build output directory (e.g., `dist/`) <br>
|
||||
into the frontend container's `/usr/share/nginx/html/assets` directory at a chosen path.<br>
|
||||
E.g.: Placing assets in `/usr/share/nginx/html/assets/plugins/my-plugin/` <br>
|
||||
would make the plugin's **`remoteEntry.js`** available at `https://production.domain/assets/plugins/my-plugin/remoteEntry.js`.
|
||||
|
||||
<br>
|
||||
|
||||
2. **Deploy Plugin Configuration**: The host's [plugin configuration file](#plugin-configuration-file-reference "See the configuration file reference") must be updated to point to the deployed assets. <br>
|
||||
This file is typically managed via infrastructure methods <br>
|
||||
(e.g., a Kubernetes configmap replacing `/app/impress/configuration/plugins/default.json` in the backend container).
|
||||
|
||||
<br>
|
||||
|
||||
Update the **`remote.url`** to the public-facing path that matches where you deployed the assets:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-custom-component",
|
||||
"remote": {
|
||||
"url": "/assets/plugins/my-plugin/remoteEntry.js",
|
||||
"name": "my_plugin",
|
||||
"module": "./MyCustomComponent"
|
||||
},
|
||||
"injection": {
|
||||
"target": "#some-element-in-the-host"
|
||||
},
|
||||
"props": {
|
||||
"message": "Hello from production!"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -66,3 +66,7 @@ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
|
||||
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
|
||||
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
|
||||
Y_PROVIDER_API_KEY=yprovider-api-key
|
||||
|
||||
# Cache
|
||||
PLUGINS_CONFIG_CACHE_TIMEOUT=0
|
||||
THEME_CUSTOMIZATION_CACHE_TIMEOUT=0
|
||||
@@ -2164,6 +2164,7 @@ class ConfigView(drf.views.APIView):
|
||||
dict_settings[setting] = getattr(settings, setting)
|
||||
|
||||
dict_settings["theme_customization"] = self._load_theme_customization()
|
||||
dict_settings["plugins"] = self._load_plugins_config()
|
||||
|
||||
return drf.response.Response(dict_settings)
|
||||
|
||||
@@ -2201,3 +2202,44 @@ class ConfigView(drf.views.APIView):
|
||||
)
|
||||
|
||||
return theme_customization
|
||||
|
||||
def _load_plugins_config(self):
|
||||
if not settings.PLUGINS_CONFIG_FILE_PATH:
|
||||
return []
|
||||
|
||||
cache_key = (
|
||||
f"plugins_config_{slugify(settings.PLUGINS_CONFIG_FILE_PATH)}"
|
||||
)
|
||||
plugins_config = cache.get(cache_key)
|
||||
if plugins_config is not None:
|
||||
return plugins_config
|
||||
|
||||
plugins_config = []
|
||||
try:
|
||||
with open(
|
||||
settings.PLUGINS_CONFIG_FILE_PATH, "r", encoding="utf-8"
|
||||
) as f:
|
||||
data = json.load(f)
|
||||
# Support both array format and object with "plugins" key
|
||||
if isinstance(data, list):
|
||||
plugins_config = data
|
||||
elif isinstance(data, dict):
|
||||
plugins_config = data.get("plugins", [])
|
||||
except FileNotFoundError:
|
||||
logger.error(
|
||||
"Plugins configuration file not found: %s",
|
||||
settings.PLUGINS_CONFIG_FILE_PATH,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(
|
||||
"Plugins configuration file is not a valid JSON: %s",
|
||||
settings.PLUGINS_CONFIG_FILE_PATH,
|
||||
)
|
||||
else:
|
||||
cache.set(
|
||||
cache_key,
|
||||
plugins_config,
|
||||
settings.PLUGINS_CONFIG_CACHE_TIMEOUT,
|
||||
)
|
||||
|
||||
return plugins_config
|
||||
|
||||
54
src/backend/impress/configuration/plugins/default.json
Normal file
54
src/backend/impress/configuration/plugins/default.json
Normal file
@@ -0,0 +1,54 @@
|
||||
[
|
||||
{
|
||||
"id": "my-custom-component-in-header",
|
||||
"remote": {
|
||||
"url": "http://localhost:3002/remoteEntry.js",
|
||||
"name": "plugin_frontend",
|
||||
"module": "MyCustomComponent"
|
||||
},
|
||||
"injection": {
|
||||
"target": "body header [data-testid=\"header-logo-link\"]",
|
||||
"position": "after",
|
||||
"observerRoots": "body header"
|
||||
},
|
||||
"props": {
|
||||
"customMessage": "Plugin Demo",
|
||||
"showDebugInfo": true
|
||||
},
|
||||
"visibility": {
|
||||
"routes": [
|
||||
"/docs/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "central-header-menu",
|
||||
"remote": {
|
||||
"url": "http://localhost:3002/remoteEntry.js",
|
||||
"name": "plugin_frontend",
|
||||
"module": "./MyCustomHeaderMenu"
|
||||
},
|
||||
"injection": {
|
||||
"target": "body header [data-testid=\"header-logo-link\"]",
|
||||
"position": "after",
|
||||
"observerRoots": "body header"
|
||||
},
|
||||
"props": {
|
||||
"icsBaseUrl": "http://localhost:8000",
|
||||
"portalBaseUrl": "http://localhost:8001"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "theme-demo-panel",
|
||||
"remote": {
|
||||
"url": "http://localhost:3002/remoteEntry.js",
|
||||
"name": "plugin_frontend",
|
||||
"module": "./ThemingDemo"
|
||||
},
|
||||
"injection": {
|
||||
"target": "body",
|
||||
"position": "append",
|
||||
"observerRoots": false
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -496,6 +496,18 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
PLUGINS_CONFIG_FILE_PATH = values.Value(
|
||||
os.path.join(BASE_DIR, "impress/configuration/plugins/default.json"),
|
||||
environ_name="PLUGINS_CONFIG_FILE_PATH",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
PLUGINS_CONFIG_CACHE_TIMEOUT = values.Value(
|
||||
60 * 60 * 2,
|
||||
environ_name="PLUGINS_CONFIG_CACHE_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Posthog
|
||||
POSTHOG_KEY = values.DictValue(
|
||||
None, environ_name="POSTHOG_KEY", environ_prefix=None
|
||||
|
||||
2
src/frontend/apps/impress-plugin/.gitignore
vendored
Normal file
2
src/frontend/apps/impress-plugin/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
@mf-types/
|
||||
node_modules/
|
||||
14
src/frontend/apps/impress-plugin/README.md
Normal file
14
src/frontend/apps/impress-plugin/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Impress Plugin
|
||||
|
||||
Minimal Module Federation Plugin for Impress
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Server runs on http://localhost:3002
|
||||
|
||||
Remote entry point: http://localhost:3002/remoteEntry.js
|
||||
37
src/frontend/apps/impress-plugin/package.json
Normal file
37
src/frontend/apps/impress-plugin/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "app-impress-plugin",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "webpack serve --mode development",
|
||||
"build": "webpack --mode production"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@module-federation/native-federation-typescript": "0.6.2",
|
||||
"@openfun/cunningham-react": "3.2.3",
|
||||
"@types/react": "19.1.1",
|
||||
"@types/react-dom": "19.1.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"html-webpack-plugin": "^5.6.3",
|
||||
"style-loader": "^4.0.0",
|
||||
"ts-loader": "^9.5.1",
|
||||
"typescript": "*",
|
||||
"webpack": "^5.101.3",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^5.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"styled-components": "6.1.19",
|
||||
"@openfun/cunningham-react": "3.2.3",
|
||||
"react-i18next": "15.7.3",
|
||||
"react-aria-components": "1.12.1",
|
||||
"@gouvfr-lasuite/ui-kit": "0.16.1",
|
||||
"yjs": "13.6.27",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"react-intersection-observer": "9.16.0",
|
||||
"@tanstack/react-query": "5.87.4"
|
||||
}
|
||||
}
|
||||
489
src/frontend/apps/impress-plugin/src/MyCustomComponent.tsx
Normal file
489
src/frontend/apps/impress-plugin/src/MyCustomComponent.tsx
Normal file
@@ -0,0 +1,489 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Button, Popover } from 'react-aria-components';
|
||||
import styled from 'styled-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthQuery } from 'impress/features/auth/api';
|
||||
import { Icon, Loading, Box, Text } from 'impress/components';
|
||||
|
||||
// Styled Components showcasing styled-components integration
|
||||
const StyledButton = styled(Button)`
|
||||
cursor: pointer;
|
||||
border: 1px solid #0066cc;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-family: Marianne, Arial, serif;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease-in-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #667eea;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledPopover = styled(Popover)`
|
||||
background-color: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid #e0e0e0;
|
||||
min-width: 400px;
|
||||
max-width: 500px;
|
||||
max-height: 600px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const TabContainer = styled.div`
|
||||
display: flex;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
background-color: #fafafa;
|
||||
`;
|
||||
|
||||
const Tab = styled.button<{ $active: boolean }>`
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
background: ${props => props.$active ? 'white' : 'transparent'};
|
||||
color: ${props => props.$active ? '#667eea' : '#666'};
|
||||
font-weight: ${props => props.$active ? '600' : '400'};
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid ${props => props.$active ? '#667eea' : 'transparent'};
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: ${props => props.$active ? 'white' : '#f5f5f5'};
|
||||
color: ${props => props.$active ? '#667eea' : '#333'};
|
||||
}
|
||||
`;
|
||||
|
||||
const TabContent = styled.div`
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
max-height: 500px;
|
||||
`;
|
||||
|
||||
const InfoCard = styled.div`
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
border-left: 4px solid #667eea;
|
||||
`;
|
||||
|
||||
const InfoRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Label = styled.span`
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 0.875rem;
|
||||
`;
|
||||
|
||||
const Value = styled.span`
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
const FeatureList = styled.ul`
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const FeatureItem = styled.li`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
`;
|
||||
|
||||
interface ComponentProps {
|
||||
customMessage?: string;
|
||||
showDebugInfo?: boolean;
|
||||
}
|
||||
|
||||
const MyCustomComponent: React.FC<ComponentProps> = ({
|
||||
customMessage = 'Plugin Showcase',
|
||||
showDebugInfo = true
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { data: authData, isLoading: authLoading } = useAuthQuery();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'user' | 'features' | 'system' | 'routing'>('user');
|
||||
const [loadingDemo, setLoadingDemo] = useState(false);
|
||||
const [currentPath, setCurrentPath] = useState<string>('');
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Get current pathname from window.location (works in plugins)
|
||||
useEffect(() => {
|
||||
setCurrentPath(window.location.pathname);
|
||||
|
||||
// Listen for route changes via popstate
|
||||
const handleRouteChange = () => {
|
||||
setCurrentPath(window.location.pathname);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handleRouteChange);
|
||||
return () => window.removeEventListener('popstate', handleRouteChange);
|
||||
}, []);
|
||||
|
||||
const handleButtonPress = () => {
|
||||
if (!isOpen) {
|
||||
// Simulate a loading state when opening
|
||||
setLoadingDemo(true);
|
||||
setTimeout(() => {
|
||||
setLoadingDemo(false);
|
||||
}, 800);
|
||||
}
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
// Derive authenticated from authData
|
||||
const authenticated = !!authData?.id;
|
||||
|
||||
// Example of accessing host features
|
||||
const userFeatures = [
|
||||
{ icon: 'person', label: 'Authentication', value: authenticated ? 'Active' : 'Inactive' },
|
||||
{ icon: 'language', label: 'Language', value: i18n.language || 'en' },
|
||||
{ icon: 'email', label: 'User Email', value: authData?.email || 'N/A' },
|
||||
{ icon: 'badge', label: 'User ID', value: authData?.id || 'N/A' },
|
||||
];
|
||||
|
||||
const systemFeatures = [
|
||||
{ icon: 'check_circle', label: 'React Hook (useAuthQuery)', enabled: true },
|
||||
{ icon: 'check_circle', label: 'Window Location API', enabled: true },
|
||||
{ icon: 'check_circle', label: 'PopState Events', enabled: true },
|
||||
{ icon: 'check_circle', label: 'i18next Integration', enabled: true },
|
||||
{ icon: 'check_circle', label: 'styled-components', enabled: true },
|
||||
{ icon: 'check_circle', label: 'react-aria-components', enabled: true },
|
||||
{ icon: 'check_circle', label: 'Host UI Components', enabled: true },
|
||||
{ icon: 'check_circle', label: '@tanstack/react-query', enabled: true },
|
||||
];
|
||||
|
||||
const pluginCapabilities = [
|
||||
'Access authentication state from host',
|
||||
'Use host UI components (Icon, Box, Text, Loading)',
|
||||
'Leverage host hooks and utilities (useAuthQuery)',
|
||||
'Read current route via window.location',
|
||||
'Listen to route changes via popstate events',
|
||||
'Integrate with i18n for translations',
|
||||
'Use styled-components for styling',
|
||||
'Implement accessible UI with react-aria',
|
||||
'Receive props from plugin configuration',
|
||||
'React to route changes via visibility config',
|
||||
];
|
||||
|
||||
const renderUserTab = () => (
|
||||
<TabContent>
|
||||
<InfoCard>
|
||||
<Text $weight="bold" $size="l" style={{ marginBottom: '12px', display: 'block' }}>
|
||||
{t('User Information')}
|
||||
</Text>
|
||||
{authLoading ? (
|
||||
<Box $align="center" $justify="center" $padding="medium">
|
||||
<Loading />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{userFeatures.map((feature, idx) => (
|
||||
<InfoRow key={idx}>
|
||||
<Label>
|
||||
<Icon iconName={feature.icon} $size="s" style={{ marginRight: '8px' }} />
|
||||
{feature.label}
|
||||
</Label>
|
||||
<Value>{feature.value}</Value>
|
||||
</InfoRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</InfoCard>
|
||||
|
||||
{showDebugInfo && authData && (
|
||||
<InfoCard>
|
||||
<Text $weight="bold" $size="m" style={{ marginBottom: '8px', display: 'block' }}>
|
||||
Raw Auth Data
|
||||
</Text>
|
||||
<pre style={{
|
||||
fontSize: '0.75rem',
|
||||
overflow: 'auto',
|
||||
maxHeight: '200px',
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
padding: '12px',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
{JSON.stringify(authData, null, 2)}
|
||||
</pre>
|
||||
</InfoCard>
|
||||
)}
|
||||
</TabContent>
|
||||
);
|
||||
|
||||
const renderFeaturesTab = () => (
|
||||
<TabContent>
|
||||
<Text $weight="bold" $size="l" style={{ marginBottom: '16px', display: 'block' }}>
|
||||
{t('Plugin Capabilities')}
|
||||
</Text>
|
||||
<FeatureList>
|
||||
{pluginCapabilities.map((capability, idx) => (
|
||||
<FeatureItem key={idx}>
|
||||
<Icon iconName="check_circle" variant="filled" $color="#4caf50" />
|
||||
<Text $size="s">{capability}</Text>
|
||||
</FeatureItem>
|
||||
))}
|
||||
</FeatureList>
|
||||
|
||||
<InfoCard style={{ marginTop: '20px' }}>
|
||||
<Text $weight="bold" $size="m" style={{ marginBottom: '12px', display: 'block' }}>
|
||||
Props from Config
|
||||
</Text>
|
||||
<InfoRow>
|
||||
<Label>Custom Message</Label>
|
||||
<Value>{customMessage}</Value>
|
||||
</InfoRow>
|
||||
<InfoRow>
|
||||
<Label>Debug Mode</Label>
|
||||
<Value>{showDebugInfo ? 'Enabled' : 'Disabled'}</Value>
|
||||
</InfoRow>
|
||||
</InfoCard>
|
||||
</TabContent>
|
||||
);
|
||||
|
||||
const renderSystemTab = () => (
|
||||
<TabContent>
|
||||
<Text $weight="bold" $size="l" style={{ marginBottom: '16px', display: 'block' }}>
|
||||
{t('Integrated Host Features')}
|
||||
</Text>
|
||||
<FeatureList>
|
||||
{systemFeatures.map((feature, idx) => (
|
||||
<FeatureItem key={idx}>
|
||||
<Icon
|
||||
iconName={feature.enabled ? 'check_circle' : 'cancel'}
|
||||
variant="filled"
|
||||
$color={feature.enabled ? '#4caf50' : '#f44336'}
|
||||
/>
|
||||
<Text $size="s">{feature.label}</Text>
|
||||
</FeatureItem>
|
||||
))}
|
||||
</FeatureList>
|
||||
|
||||
<InfoCard style={{ marginTop: '20px' }}>
|
||||
<Text $weight="bold" $size="m" style={{ marginBottom: '12px', display: 'block' }}>
|
||||
Available Host Components
|
||||
</Text>
|
||||
<Text $size="xs" style={{ lineHeight: '1.6' }}>
|
||||
Icon, Loading, Box, Text, Link, Card, Modal, DropButton, DropdownMenu,
|
||||
InfiniteScroll, QuickSearch, Separators, TextErrors, and more...
|
||||
</Text>
|
||||
</InfoCard>
|
||||
</TabContent>
|
||||
);
|
||||
|
||||
const renderRoutingTab = () => (
|
||||
<TabContent>
|
||||
<Text $weight="bold" $size="l" style={{ marginBottom: '16px', display: 'block' }}>
|
||||
{t('Route Information (Plugin-Safe)')}
|
||||
</Text>
|
||||
|
||||
<InfoCard>
|
||||
<Text $weight="bold" $size="m" style={{ marginBottom: '12px', display: 'block' }}>
|
||||
Current Route Information
|
||||
</Text>
|
||||
<InfoRow>
|
||||
<Label>
|
||||
<Icon iconName="route" $size="s" style={{ marginRight: '8px' }} />
|
||||
Current Path
|
||||
</Label>
|
||||
<Value>{currentPath || '/'}</Value>
|
||||
</InfoRow>
|
||||
<InfoRow>
|
||||
<Label>
|
||||
<Icon iconName="link" $size="s" style={{ marginRight: '8px' }} />
|
||||
Full URL
|
||||
</Label>
|
||||
<Value style={{ fontSize: '0.75rem', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{typeof window !== 'undefined' ? window.location.href : 'N/A'}
|
||||
</Value>
|
||||
</InfoRow>
|
||||
<InfoRow>
|
||||
<Label>
|
||||
<Icon iconName="tag" $size="s" style={{ marginRight: '8px' }} />
|
||||
URL Hash
|
||||
</Label>
|
||||
<Value>{typeof window !== 'undefined' ? (window.location.hash || 'none') : 'N/A'}</Value>
|
||||
</InfoRow>
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard>
|
||||
<Text $weight="bold" $size="m" style={{ marginBottom: '12px', display: 'block' }}>
|
||||
Plugin Routing Capabilities
|
||||
</Text>
|
||||
<FeatureList>
|
||||
<FeatureItem>
|
||||
<Icon iconName="check_circle" variant="filled" $color="#4caf50" />
|
||||
<Text $size="s">Read pathname via window.location</Text>
|
||||
</FeatureItem>
|
||||
<FeatureItem>
|
||||
<Icon iconName="check_circle" variant="filled" $color="#4caf50" />
|
||||
<Text $size="s">Listen to popstate events for route changes</Text>
|
||||
</FeatureItem>
|
||||
<FeatureItem>
|
||||
<Icon iconName="check_circle" variant="filled" $color="#4caf50" />
|
||||
<Text $size="s">Use visibility.routes in config for conditional rendering</Text>
|
||||
</FeatureItem>
|
||||
<FeatureItem>
|
||||
<Icon iconName="info" variant="filled" $color="#2196f3" />
|
||||
<Text $size="s">Navigate using standard anchor tags or window APIs</Text>
|
||||
</FeatureItem>
|
||||
<FeatureItem>
|
||||
<Icon iconName="warning" variant="filled" $color="#ff9800" />
|
||||
<Text $size="s">Next.js router hooks not available (outside RouterContext)</Text>
|
||||
</FeatureItem>
|
||||
</FeatureList>
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard style={{ background: 'linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%)', borderLeft: '4px solid #ff9800' }}>
|
||||
<Text $weight="bold" $size="s" style={{ marginBottom: '8px', display: 'block' }}>
|
||||
⚠️ Important Note
|
||||
</Text>
|
||||
<Text $size="xs" style={{ lineHeight: '1.6', marginBottom: '8px' }}>
|
||||
Plugins render outside the Next.js RouterContext, so useRouter() and usePathname()
|
||||
are not available. Instead, use:
|
||||
</Text>
|
||||
<ul style={{ fontSize: '0.75rem', lineHeight: '1.8', marginLeft: '20px' }}>
|
||||
<li><code>window.location.pathname</code> - Get current path</li>
|
||||
<li><code>window.addEventListener('popstate')</code> - Detect route changes</li>
|
||||
<li>Plugin config <code>visibility.routes</code> - Control when plugin appears</li>
|
||||
</ul>
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard style={{ background: 'linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%)', borderLeft: '4px solid #4caf50' }}>
|
||||
<Text $weight="bold" $size="s" style={{ marginBottom: '8px', display: 'block' }}>
|
||||
✅ Best Practice
|
||||
</Text>
|
||||
<Text $size="xs" style={{ lineHeight: '1.6' }}>
|
||||
For route-aware plugins, use the <code>visibility.routes</code> config option with
|
||||
glob patterns (e.g., <code>["/docs/*", "!/docs/secret"]</code>). The plugin system
|
||||
automatically shows/hides your plugin based on the current route!
|
||||
</Text>
|
||||
</InfoCard>
|
||||
</TabContent>
|
||||
);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<Box $padding="small">
|
||||
<Loading />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledButton
|
||||
ref={triggerRef}
|
||||
onPress={handleButtonPress}
|
||||
aria-label="Open Plugin Showcase"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<Icon iconName="extension" variant="filled" />
|
||||
{customMessage}
|
||||
</StyledButton>
|
||||
|
||||
<StyledPopover
|
||||
triggerRef={triggerRef}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
offset={10}
|
||||
>
|
||||
{loadingDemo ? (
|
||||
<Box
|
||||
$padding="large"
|
||||
$align="center"
|
||||
$justify="center"
|
||||
$height="300px"
|
||||
>
|
||||
<Loading />
|
||||
<Text $size="s" style={{ marginTop: '16px', color: '#666' }}>
|
||||
Fake Loading...
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<TabContainer>
|
||||
<Tab
|
||||
$active={activeTab === 'user'}
|
||||
onClick={() => setActiveTab('user')}
|
||||
>
|
||||
<Icon iconName="person" $size="s" /> User
|
||||
</Tab>
|
||||
<Tab
|
||||
$active={activeTab === 'features'}
|
||||
onClick={() => setActiveTab('features')}
|
||||
>
|
||||
<Icon iconName="settings" $size="s" /> Features
|
||||
</Tab>
|
||||
<Tab
|
||||
$active={activeTab === 'system'}
|
||||
onClick={() => setActiveTab('system')}
|
||||
>
|
||||
<Icon iconName="integration_instructions" $size="s" /> System
|
||||
</Tab>
|
||||
<Tab
|
||||
$active={activeTab === 'routing'}
|
||||
onClick={() => setActiveTab('routing')}
|
||||
>
|
||||
<Icon iconName="route" $size="s" /> Routing
|
||||
</Tab>
|
||||
</TabContainer>
|
||||
|
||||
{activeTab === 'user' && renderUserTab()}
|
||||
{activeTab === 'features' && renderFeaturesTab()}
|
||||
{activeTab === 'system' && renderSystemTab()}
|
||||
{activeTab === 'routing' && renderRoutingTab()}
|
||||
</>
|
||||
)}
|
||||
</StyledPopover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyCustomComponent;
|
||||
109
src/frontend/apps/impress-plugin/src/MyCustomHeaderMenu.css
Normal file
109
src/frontend/apps/impress-plugin/src/MyCustomHeaderMenu.css
Normal file
@@ -0,0 +1,109 @@
|
||||
|
||||
#central-menu-wrapper {
|
||||
flex-direction: row;
|
||||
align-self: stretch;
|
||||
align-items: stretch;
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
#central-menu-wrapper > * {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#central-menu-wrapper > a > div {
|
||||
height: 100%;
|
||||
margin: unset;
|
||||
}
|
||||
|
||||
#central-menu-wrapper > a > div > svg {
|
||||
width: 82px;
|
||||
}
|
||||
|
||||
#central-menu-wrapper + div > button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#central-menu-wrapper #central-menu {
|
||||
position: relative;
|
||||
color: var(--c--theme--colors--greyscale-text);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#central-menu-wrapper #nav-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
padding: 0 22px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#central-menu-wrapper #nav-button:hover {
|
||||
background-color: var(
|
||||
--c--components--button--primary-text--background--color-hover
|
||||
);
|
||||
}
|
||||
|
||||
#central-menu-wrapper #nav-button.active {
|
||||
background-color: var(--c--components--button--primary--background--color);
|
||||
color: var(--c--theme--colors--greyscale-000);
|
||||
}
|
||||
|
||||
[data-testid="od-menu-popover"] {
|
||||
background: unset !important;
|
||||
border: unset !important;
|
||||
}
|
||||
|
||||
#nav-content {
|
||||
position: absolute;
|
||||
width: max-content;
|
||||
background: var(--c--theme--colors--greyscale-000);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--c--theme--colors--card-border);
|
||||
border-top: 4px solid var(--c--components--button--primary--background--color);
|
||||
max-width: 280px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 4px 0 20px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#nav-content .menu-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#nav-content .menu-category {
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin: 20px 24px 8px;
|
||||
}
|
||||
|
||||
#nav-content .menu-entries {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#nav-content .menu-link {
|
||||
display: flex;
|
||||
padding: 4px 24px;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#nav-content .menu-link:hover {
|
||||
background-color: var(
|
||||
--c--components--button--primary-text--background--color-hover
|
||||
);
|
||||
}
|
||||
|
||||
#nav-content .menu-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
283
src/frontend/apps/impress-plugin/src/MyCustomHeaderMenu.tsx
Normal file
283
src/frontend/apps/impress-plugin/src/MyCustomHeaderMenu.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import './MyCustomHeaderMenu.css';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Button, Popover } from 'react-aria-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
import { useAuthQuery } from 'impress/features/auth/api';
|
||||
import { Icon, Loading } from 'impress/components';
|
||||
|
||||
interface NavigationCategory {
|
||||
identifier: string;
|
||||
display_name: string;
|
||||
entries: NavigationEntry[];
|
||||
}
|
||||
|
||||
interface NavigationEntry {
|
||||
identifier: string;
|
||||
link: string;
|
||||
target: string;
|
||||
display_name: string;
|
||||
icon_url: string;
|
||||
}
|
||||
|
||||
const StyledPopover = styled(Popover)`
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #dddddd;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
font-family: Marianne, Arial, serif;
|
||||
font-weight: 500;
|
||||
font-size: 0.938rem;
|
||||
padding: 0;
|
||||
text-wrap: nowrap;
|
||||
`;
|
||||
|
||||
// Fake navigation response for development/debugging
|
||||
const fakeNavigationData = {
|
||||
categories: [
|
||||
{
|
||||
identifier: 'fake-cat',
|
||||
display_name: 'Dummy Category',
|
||||
entries: [
|
||||
{
|
||||
identifier: 'fake-entry-1',
|
||||
link: 'https://www.google.com',
|
||||
target: '_blank',
|
||||
display_name: 'Google',
|
||||
icon_url: 'https://placehold.co/24',
|
||||
},
|
||||
{
|
||||
identifier: 'fake-entry-2',
|
||||
link: 'https://www.example.com',
|
||||
target: '_blank',
|
||||
display_name: 'Example',
|
||||
icon_url: 'https://placehold.co/24',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const formatLanguage = (language: string): string => {
|
||||
const [lang, region] = language.split('-');
|
||||
return region
|
||||
? `${lang}-${lang.toUpperCase()}`
|
||||
: `${language}-${language.toUpperCase()}`;
|
||||
};
|
||||
|
||||
const fetchNavigation = async (
|
||||
language: string,
|
||||
baseUrl: string,
|
||||
): Promise<NavigationCategory[] | null> => {
|
||||
// Uncomment below for development/debugging with fake data
|
||||
return fakeNavigationData.categories;
|
||||
|
||||
try {
|
||||
if (!baseUrl) {
|
||||
console.warn('[CentralMenu] ICS_BASE_URL not configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${baseUrl}/navigation.json?language=${language}`,
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
redirect: 'follow',
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType?.includes('application/json')) {
|
||||
const jsonData = await response.json() as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
jsonData &&
|
||||
typeof jsonData === 'object' &&
|
||||
'categories' in jsonData &&
|
||||
Array.isArray(jsonData.categories)
|
||||
) {
|
||||
return jsonData.categories as NavigationCategory[];
|
||||
} else {
|
||||
console.warn('[CentralMenu] Invalid JSON format in navigation response.');
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
console.warn('[CentralMenu] Unexpected content type:', contentType);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
console.warn('[CentralMenu] Navigation fetch failed. Status:', response.status);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CentralMenu] Error fetching navigation:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface CentralMenuProps {
|
||||
icsBaseUrl?: string;
|
||||
portalBaseUrl?: string;
|
||||
}
|
||||
|
||||
const CentralMenu: React.FC<CentralMenuProps> = ({
|
||||
icsBaseUrl = '',
|
||||
portalBaseUrl = '',
|
||||
}) => {
|
||||
const { i18n, t } = useTranslation();
|
||||
const { data: auth } = useAuthQuery();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [navigation, setNavigation] = useState<NavigationCategory[] | null>(null);
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleToggle = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleIframeLoad = async () => {
|
||||
const language = i18n.language ? formatLanguage(i18n.language) : 'en-US';
|
||||
const navData = await fetchNavigation(language, icsBaseUrl);
|
||||
|
||||
if (navData) {
|
||||
setNavigation(navData);
|
||||
setStatus('success');
|
||||
} else {
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle language changes - refetch navigation when language changes
|
||||
useEffect(() => {
|
||||
// Only refetch if iframe has already loaded (navigation exists or error occurred)
|
||||
if (status !== 'loading') {
|
||||
handleIframeLoad();
|
||||
}
|
||||
}, [i18n.language]);
|
||||
|
||||
if (!auth?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderNavigation = () => {
|
||||
if (!navigation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return navigation.map((category) => (
|
||||
<li
|
||||
key={category.identifier}
|
||||
data-testid="od-menu-app-category"
|
||||
>
|
||||
<span className="menu-category">{category.display_name}</span>
|
||||
<ul className="menu-entries" data-testid="od-menu-apps">
|
||||
{category.entries.map((entry) => (
|
||||
<li key={entry.identifier} data-testid="od-menu-app">
|
||||
<a
|
||||
href={entry.link}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
target={entry.target}
|
||||
className="menu-link"
|
||||
role="menuitem"
|
||||
>
|
||||
<img
|
||||
alt={entry.display_name}
|
||||
src={entry.icon_url}
|
||||
className="menu-icon"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{entry.display_name}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{icsBaseUrl && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title="opendesk login"
|
||||
src={`${icsBaseUrl}/silent`}
|
||||
data-testid="od-menu-iframe"
|
||||
hidden
|
||||
onLoad={handleIframeLoad}
|
||||
style={{ display: 'none', visibility: 'hidden' }}
|
||||
/>
|
||||
)}
|
||||
<nav id="central-menu" role="navigation" aria-label="Main Navigation">
|
||||
<StyledButton
|
||||
id="nav-button"
|
||||
className={isOpen ? 'active' : ''}
|
||||
ref={triggerRef}
|
||||
onPress={handleToggle}
|
||||
aria-label="Toggle Central Menu"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="nav-content"
|
||||
data-testid="od-menu-open-button"
|
||||
>
|
||||
<Icon iconName="apps" aria-hidden="true" />
|
||||
</StyledButton>
|
||||
<StyledPopover
|
||||
triggerRef={triggerRef}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
offset={0}
|
||||
containerPadding={0}
|
||||
data-testid="od-menu-popover"
|
||||
>
|
||||
<div id="nav-content">
|
||||
<ul className="menu-list">
|
||||
{status === 'error' ? (
|
||||
<li className="menu-category" data-testid="od-menu-app-category">
|
||||
<small>
|
||||
{t('Navigation could not be accessed.')}
|
||||
{portalBaseUrl && (
|
||||
<>
|
||||
<br />
|
||||
<a
|
||||
href={portalBaseUrl}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t('Try logging out and logging in again.')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</small>
|
||||
</li>
|
||||
) : status === 'loading' ? (
|
||||
<li className="menu-category" data-testid="od-menu-app-category">
|
||||
<Loading />
|
||||
</li>
|
||||
) : (
|
||||
renderNavigation()
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</StyledPopover>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CentralMenu;
|
||||
220
src/frontend/apps/impress-plugin/src/ThemingComponent.tsx
Normal file
220
src/frontend/apps/impress-plugin/src/ThemingComponent.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
interface ThemeColors {
|
||||
// Primary colors
|
||||
'primary-100'?: string;
|
||||
'primary-200'?: string;
|
||||
'primary-300'?: string;
|
||||
'primary-400'?: string;
|
||||
'primary-500'?: string;
|
||||
'primary-600'?: string;
|
||||
'primary-700'?: string;
|
||||
'primary-800'?: string;
|
||||
'primary-900'?: string;
|
||||
'primary-text'?: string;
|
||||
'primary-bg'?: string;
|
||||
|
||||
// Secondary colors
|
||||
'secondary-100'?: string;
|
||||
'secondary-200'?: string;
|
||||
'secondary-300'?: string;
|
||||
'secondary-400'?: string;
|
||||
'secondary-500'?: string;
|
||||
'secondary-600'?: string;
|
||||
'secondary-700'?: string;
|
||||
'secondary-800'?: string;
|
||||
'secondary-900'?: string;
|
||||
'secondary-text'?: string;
|
||||
'secondary-bg'?: string;
|
||||
|
||||
// Info colors
|
||||
'info-100'?: string;
|
||||
'info-200'?: string;
|
||||
'info-300'?: string;
|
||||
'info-400'?: string;
|
||||
'info-500'?: string;
|
||||
'info-600'?: string;
|
||||
'info-700'?: string;
|
||||
'info-800'?: string;
|
||||
'info-900'?: string;
|
||||
'info-text'?: string;
|
||||
|
||||
// Success colors
|
||||
'success-100'?: string;
|
||||
'success-200'?: string;
|
||||
'success-300'?: string;
|
||||
'success-400'?: string;
|
||||
'success-500'?: string;
|
||||
'success-600'?: string;
|
||||
|
||||
// Warning colors
|
||||
'warning-100'?: string;
|
||||
'warning-200'?: string;
|
||||
'warning-300'?: string;
|
||||
'warning-400'?: string;
|
||||
'warning-500'?: string;
|
||||
'warning-600'?: string;
|
||||
|
||||
// Error colors
|
||||
'error-100'?: string;
|
||||
'error-200'?: string;
|
||||
'error-300'?: string;
|
||||
'error-400'?: string;
|
||||
'error-500'?: string;
|
||||
'error-600'?: string;
|
||||
|
||||
// Greyscale
|
||||
'greyscale-000'?: string;
|
||||
'greyscale-100'?: string;
|
||||
'greyscale-200'?: string;
|
||||
'greyscale-300'?: string;
|
||||
'greyscale-400'?: string;
|
||||
'greyscale-500'?: string;
|
||||
'greyscale-600'?: string;
|
||||
'greyscale-700'?: string;
|
||||
'greyscale-800'?: string;
|
||||
'greyscale-900'?: string;
|
||||
}
|
||||
|
||||
interface ThemeSpacings {
|
||||
'xxxxs'?: string;
|
||||
'xxxs'?: string;
|
||||
'xxs'?: string;
|
||||
'xs'?: string;
|
||||
's'?: string;
|
||||
'b'?: string;
|
||||
'st'?: string;
|
||||
't'?: string;
|
||||
'l'?: string;
|
||||
'xl'?: string;
|
||||
'xxl'?: string;
|
||||
'xxxl'?: string;
|
||||
}
|
||||
|
||||
interface ThemeFontSizes {
|
||||
'xxxxs'?: string;
|
||||
'xxxs'?: string;
|
||||
'xxs'?: string;
|
||||
'xs'?: string;
|
||||
's'?: string;
|
||||
'm'?: string;
|
||||
'l'?: string;
|
||||
'xl'?: string;
|
||||
'xxl'?: string;
|
||||
'xxxl'?: string;
|
||||
'xxxxl'?: string;
|
||||
}
|
||||
|
||||
interface ThemingComponentProps {
|
||||
colors?: ThemeColors;
|
||||
spacings?: ThemeSpacings;
|
||||
fontSizes?: ThemeFontSizes;
|
||||
customVars?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* ThemingComponent - Override Cunningham design tokens from a plugin
|
||||
*
|
||||
* This component allows plugins to customize the host application's theme by
|
||||
* setting CSS custom properties (CSS variables).
|
||||
*
|
||||
* ⚠️ IMPORTANT: This component ONLY updates CSS variables, not the Zustand store.
|
||||
* This means:
|
||||
* - ✅ Components using CSS variables like `var(--c--theme--colors--primary-text)` WILL update
|
||||
* - ❌ Components using JS values like `colorsTokens['primary-text']` will NOT update
|
||||
*
|
||||
* For host components to be themeable by plugins, they must use CSS variables instead of
|
||||
* JS token values. Example:
|
||||
*
|
||||
* ❌ Don't use: `$color={colorsTokens['primary-text']}`
|
||||
* ✅ Use instead: `$theme="primary" $variation="text"` (which generates CSS vars)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ThemingComponent
|
||||
* colors={{
|
||||
* 'primary-500': '#FF6B6B',
|
||||
* 'primary-600': '#EE5A6F',
|
||||
* 'primary-text': '#FF6B6B',
|
||||
* 'secondary-500': '#4ECDC4',
|
||||
* }}
|
||||
* spacings={{
|
||||
* 's': '12px',
|
||||
* 'm': '24px',
|
||||
* }}
|
||||
* customVars={{
|
||||
* '--custom-header-height': '80px',
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const ThemingComponent: React.FC<ThemingComponentProps> = ({
|
||||
colors = {},
|
||||
spacings = {},
|
||||
fontSizes = {},
|
||||
customVars = {},
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Store original values for cleanup
|
||||
const originalValues: Record<string, string> = {};
|
||||
|
||||
console.log('[ThemingComponent] Applying CSS variable overrides');
|
||||
|
||||
// Apply CSS custom properties (for CSS variable-based usage)
|
||||
Object.entries(colors).forEach(([key, value]) => {
|
||||
const cssVar = `--c--theme--colors--${key}`;
|
||||
originalValues[cssVar] = root.style.getPropertyValue(cssVar);
|
||||
root.style.setProperty(cssVar, value);
|
||||
});
|
||||
|
||||
// Apply spacing tokens
|
||||
Object.entries(spacings).forEach(([key, value]) => {
|
||||
const cssVar = `--c--theme--spacings--${key}`;
|
||||
originalValues[cssVar] = root.style.getPropertyValue(cssVar);
|
||||
root.style.setProperty(cssVar, value);
|
||||
});
|
||||
|
||||
// Apply font size tokens
|
||||
Object.entries(fontSizes).forEach(([key, value]) => {
|
||||
const cssVar = `--c--theme--font--sizes--${key}`;
|
||||
originalValues[cssVar] = root.style.getPropertyValue(cssVar);
|
||||
root.style.setProperty(cssVar, value);
|
||||
});
|
||||
|
||||
// Apply custom CSS variables
|
||||
Object.entries(customVars).forEach(([key, value]) => {
|
||||
originalValues[key] = root.style.getPropertyValue(key);
|
||||
root.style.setProperty(key, value);
|
||||
});
|
||||
|
||||
console.log('[ThemingComponent] Applied CSS variable overrides:', {
|
||||
colors: Object.keys(colors).length,
|
||||
spacings: Object.keys(spacings).length,
|
||||
fontSizes: Object.keys(fontSizes).length,
|
||||
customVars: Object.keys(customVars).length,
|
||||
});
|
||||
|
||||
// Cleanup: restore original values on unmount
|
||||
return () => {
|
||||
console.log('[ThemingComponent] Restoring original CSS variables');
|
||||
|
||||
// Restore CSS variables
|
||||
Object.entries(originalValues).forEach(([cssVar, originalValue]) => {
|
||||
if (originalValue) {
|
||||
root.style.setProperty(cssVar, originalValue);
|
||||
} else {
|
||||
root.style.removeProperty(cssVar);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[ThemingComponent] Restored original CSS variables');
|
||||
};
|
||||
}, [colors, spacings, fontSizes, customVars]);
|
||||
|
||||
// This component doesn't render anything visible
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ThemingComponent;
|
||||
163
src/frontend/apps/impress-plugin/src/ThemingDemo.tsx
Normal file
163
src/frontend/apps/impress-plugin/src/ThemingDemo.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useState } from 'react';
|
||||
import ThemingComponent from './ThemingComponent';
|
||||
|
||||
/**
|
||||
* ThemingDemo - Interactive demo component to test theme overrides
|
||||
*
|
||||
* This component demonstrates the ThemingComponent by providing
|
||||
* a UI to dynamically change theme tokens and see the results.
|
||||
*/
|
||||
const ThemingDemo: React.FC = () => {
|
||||
const [primaryColor, setPrimaryColor] = useState('#667eea');
|
||||
const [secondaryColor, setSecondaryColor] = useState('#764ba2');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
|
||||
return (
|
||||
<>
|
||||
{enabled && (
|
||||
<ThemingComponent
|
||||
colors={{
|
||||
// Primary color variants
|
||||
'primary-500': primaryColor,
|
||||
'primary-600': secondaryColor,
|
||||
'primary-text': primaryColor,
|
||||
'primary-bg': primaryColor,
|
||||
|
||||
// Secondary color variants
|
||||
'secondary-500': secondaryColor,
|
||||
'secondary-text': secondaryColor,
|
||||
'secondary-bg': secondaryColor,
|
||||
}}
|
||||
customVars={{
|
||||
'--demo-gradient': `linear-gradient(135deg, ${primaryColor} 0%, ${secondaryColor} 100%)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid var(--c--theme--colors--greyscale-300)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
zIndex: 9999,
|
||||
minWidth: '300px',
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: '0 0 16px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: 'var(--c--theme--colors--greyscale-900)',
|
||||
}}>
|
||||
🎨 Theme Override Demo
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
Enable Theme Override
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '4px',
|
||||
color: 'var(--c--theme--colors--greyscale-700)',
|
||||
}}>
|
||||
Primary Color
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={primaryColor}
|
||||
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||
disabled={!enabled}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '36px',
|
||||
border: '1px solid var(--c--theme--colors--greyscale-300)',
|
||||
borderRadius: '6px',
|
||||
cursor: enabled ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
/>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--c--theme--colors--greyscale-500)',
|
||||
}}>
|
||||
{primaryColor}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '4px',
|
||||
color: 'var(--c--theme--colors--greyscale-700)',
|
||||
}}>
|
||||
Secondary Color
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={secondaryColor}
|
||||
onChange={(e) => setSecondaryColor(e.target.value)}
|
||||
disabled={!enabled}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '36px',
|
||||
border: '1px solid var(--c--theme--colors--greyscale-300)',
|
||||
borderRadius: '6px',
|
||||
cursor: enabled ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
/>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--c--theme--colors--greyscale-500)',
|
||||
}}>
|
||||
{secondaryColor}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
background: 'var(--demo-gradient, linear-gradient(135deg, #667eea 0%, #764ba2 100%))',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
fontWeight: '600',
|
||||
}}>
|
||||
Preview: Gradient with current colors
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--c--theme--colors--greyscale-600)',
|
||||
lineHeight: '1.4',
|
||||
}}>
|
||||
💡 This demo overrides primary-500, primary-600, and secondary-500 css variables. It only works with CSS.
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemingDemo;
|
||||
2
src/frontend/apps/impress-plugin/src/index.tsx
Normal file
2
src/frontend/apps/impress-plugin/src/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
// This file is just to satisfy webpack's entry point requirement
|
||||
// The actual component is exposed via Module Federation
|
||||
20
src/frontend/apps/impress-plugin/tsconfig.json
Normal file
20
src/frontend/apps/impress-plugin/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"*": ["./@mf-types/*"],
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
104
src/frontend/apps/impress-plugin/webpack.config.js
Normal file
104
src/frontend/apps/impress-plugin/webpack.config.js
Normal file
@@ -0,0 +1,104 @@
|
||||
const path = require('path');
|
||||
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
|
||||
const {
|
||||
NativeFederationTypeScriptHost,
|
||||
} = require('@module-federation/native-federation-typescript/webpack');
|
||||
const {
|
||||
NativeFederationTypeScriptHost: NativeFederationTypeScriptHostCore,
|
||||
} = require('@module-federation/native-federation-typescript');
|
||||
|
||||
const moduleFederationConfig = {
|
||||
name: 'plugin_frontend',
|
||||
filename: 'remoteEntry.js',
|
||||
exposes: {
|
||||
'./MyCustomComponent': './src/MyCustomComponent.tsx',
|
||||
'./MyCustomHeaderMenu': './src/MyCustomHeaderMenu.tsx',
|
||||
'./ThemingComponent': './src/ThemingComponent.tsx',
|
||||
'./ThemingDemo': './src/ThemingDemo.tsx',
|
||||
},
|
||||
remotes: {
|
||||
impress: 'impress@http://localhost:3000/_next/static/chunks/remoteEntry.js',
|
||||
},
|
||||
shared: {
|
||||
react: { singleton: true },
|
||||
'react-dom': { singleton: true },
|
||||
'styled-components': { singleton: true },
|
||||
'react-aria-components': { singleton: true },
|
||||
'@openfun/cunningham-react': { singleton: true },
|
||||
'react-i18next': { singleton: true },
|
||||
'yjs': { singleton: true },
|
||||
'@gouvfr-lasuite/ui-kit': { singleton: true },
|
||||
'clsx': { singleton: true },
|
||||
'cmdk': { singleton: true },
|
||||
'react-intersection-observer': { singleton: true },
|
||||
'@tanstack/react-query': { singleton: true },
|
||||
'zustand': { singleton: true },
|
||||
},
|
||||
};
|
||||
|
||||
let mfTypesReady;
|
||||
const ensureFederatedTypesPlugin = {
|
||||
apply(compiler) {
|
||||
compiler.hooks.beforeCompile.tapPromise(
|
||||
'EnsureFederatedTypes',
|
||||
async () => {
|
||||
if (!mfTypesReady) {
|
||||
const downloader = NativeFederationTypeScriptHostCore.raw({
|
||||
moduleFederationConfig,
|
||||
});
|
||||
mfTypesReady = downloader.writeBundle();
|
||||
}
|
||||
await mfTypesReady;
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const dev = argv.mode !== 'production';
|
||||
|
||||
return {
|
||||
entry: './src/index.tsx',
|
||||
mode: dev ? 'development' : 'production',
|
||||
devServer: dev ? {
|
||||
port: 3002,
|
||||
hot: true,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization',
|
||||
},
|
||||
} : undefined,
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
publicPath: dev ? 'http://localhost:3002/' : undefined,
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.tsx', '.ts', '.js', '.jsx'],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: 'ts-loader',
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new ModuleFederationPlugin(moduleFederationConfig),
|
||||
...(dev
|
||||
? [
|
||||
ensureFederatedTypesPlugin,
|
||||
NativeFederationTypeScriptHost({
|
||||
moduleFederationConfig,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,3 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=
|
||||
NEXT_PUBLIC_SW_DEACTIVATED=
|
||||
NEXT_PUBLIC_PUBLISH_AS_MIT=true
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
NEXT_PUBLIC_SW_DEACTIVATED=false
|
||||
NEXT_PUBLIC_PUBLISH_AS_MIT=false
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
NEXT_PUBLIC_PUBLISH_AS_MIT=false
|
||||
NEXT_PUBLIC_SW_DEACTIVATED=true
|
||||
NEXT_PUBLIC_DEVELOP_PLUGINS=false
|
||||
|
||||
|
||||
305
src/frontend/apps/impress/mf.config.js
Normal file
305
src/frontend/apps/impress/mf.config.js
Normal file
@@ -0,0 +1,305 @@
|
||||
const path = require('path');
|
||||
|
||||
const ts = require('typescript');
|
||||
|
||||
/**
|
||||
* @file This file configures Module Federation for the 'impress' application.
|
||||
* It automatically exposes components and shares dependencies.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extracts the root name of a package from an import specifier.
|
||||
* e.g., '@scope/name/foo' becomes '@scope/name', and 'next/link' becomes 'next'.
|
||||
* @param {string} spec - The import specifier.
|
||||
* @returns {string} The root package name.
|
||||
*/
|
||||
function pkgRootName(spec) {
|
||||
// '@scope/name/foo' -> '@scope/name', 'next/link' -> 'next'
|
||||
if (spec.startsWith('@')) {
|
||||
const [scope, name] = spec.split('/');
|
||||
return `${scope}/${name || ''}`;
|
||||
}
|
||||
return spec.split('/')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an import specifier is a relative path or a path alias.
|
||||
* @param {string} spec - The import specifier.
|
||||
* @returns {boolean} True if the path is relative or an alias.
|
||||
*/
|
||||
function isRelativeOrAlias(spec) {
|
||||
return spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('@/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the key for the 'exposes' object from an absolute file path.
|
||||
* This creates a public path like './components/Button' from a file path.
|
||||
* @param {string} absPath - The absolute path to the file.
|
||||
* @param {string} root - The project root directory.
|
||||
* @returns {string} The key for the 'exposes' object.
|
||||
*/
|
||||
function buildExposeKey(absPath, root) {
|
||||
const relPath = path.relative(root, absPath).replace(/\\/g, '/'); // Convert backslashes to forward slashes
|
||||
|
||||
// Remove 'src/' prefix to create cleaner public paths
|
||||
const pathWithoutSrc = relPath.startsWith('src/')
|
||||
? relPath.substring(4)
|
||||
: relPath;
|
||||
|
||||
const relNoExt = pathWithoutSrc.replace(/\.(t|j)sx?$/, ''); // Remove file extension
|
||||
// Remove '/index' from the end of the path to allow importing the directory
|
||||
const withoutIndex = relNoExt.replace(/\/index$/, '');
|
||||
return './' + withoutIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans specified folders for TypeScript/JavaScript files to expose via Module Federation.
|
||||
* It uses the TypeScript compiler API to find all files with actual runtime exports.
|
||||
* @param {string[]} folders - An array of folder paths to scan.
|
||||
* @returns {{exposes: Record<string, string>, program: ts.Program}} An object containing the exposed modules and the TypeScript program instance.
|
||||
*/
|
||||
function makeExposes(folders) {
|
||||
const projectRoot = process.cwd();
|
||||
const tsconfigPath = path.resolve(projectRoot, 'tsconfig.json');
|
||||
|
||||
// Read and parse the tsconfig.json file
|
||||
const cfg = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
|
||||
if (cfg.error) {
|
||||
throw new Error(formatTsError('Failed to read tsconfig', cfg.error));
|
||||
}
|
||||
|
||||
const parsed = ts.parseJsonConfigFileContent(
|
||||
cfg.config,
|
||||
ts.sys,
|
||||
path.dirname(tsconfigPath),
|
||||
undefined,
|
||||
tsconfigPath,
|
||||
);
|
||||
|
||||
// Create a TypeScript program to analyze the source files
|
||||
const program = ts.createProgram({
|
||||
rootNames: parsed.fileNames,
|
||||
options: parsed.options,
|
||||
});
|
||||
const checker = program.getTypeChecker();
|
||||
|
||||
const dirSet = new Set(
|
||||
folders.map((f) => path.resolve(process.cwd(), f).replace(/\\/g, '/')),
|
||||
);
|
||||
const exposes = {};
|
||||
const used = new Set(); // Tracks used expose keys to detect duplicates
|
||||
|
||||
for (const sf of program.getSourceFiles()) {
|
||||
// Skip declaration files (.d.ts)
|
||||
if (sf.isDeclarationFile) {
|
||||
continue;
|
||||
}
|
||||
const abs = sf.fileName.replace(/\\/g, '/');
|
||||
|
||||
// Only include files from the specified folders
|
||||
if (
|
||||
![...dirSet].some((dir) =>
|
||||
abs.startsWith(dir.endsWith('/') ? dir : dir + '/'),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only include valid script files
|
||||
const ext = path.extname(abs).toLowerCase();
|
||||
if (!['.ts', '.tsx', '.js', '.jsx'].includes(ext)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Exclude node_modules, test files, and mocks
|
||||
if (
|
||||
abs.includes('/node_modules/') ||
|
||||
/[.-](test|spec|stories)\.(t|j)sx?$/.test(abs) ||
|
||||
abs.includes('/__tests__/') ||
|
||||
abs.includes('/__mocks__/')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const moduleSymbol = sf.symbol;
|
||||
if (!moduleSymbol) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the module has any runtime exports (values, not just types)
|
||||
const hasRuntime = checker.getExportsOfModule(moduleSymbol).some((sym) => {
|
||||
const tgt =
|
||||
sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
|
||||
return (tgt.getFlags() & ts.SymbolFlags.Value) !== 0;
|
||||
});
|
||||
if (!hasRuntime) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = buildExposeKey(abs, process.cwd());
|
||||
const rel = './' + path.relative(process.cwd(), abs).replace(/\\/g, '/');
|
||||
|
||||
// Handle duplicate keys, preferring non-index files
|
||||
if (used.has(key)) {
|
||||
const isNewFileIndex = path.basename(abs, path.extname(abs)) === 'index';
|
||||
const existingPath = exposes[key];
|
||||
|
||||
console.warn(
|
||||
`[makeExposes] Duplicate expose key "${key}". ` +
|
||||
`${isNewFileIndex ? 'Skipping index file' : 'Keeping first occurrence'}: "${rel}". ` +
|
||||
`Existing: "${existingPath}".`,
|
||||
);
|
||||
if (isNewFileIndex) {
|
||||
continue; // Index files lose in case of conflicts
|
||||
}
|
||||
// Otherwise: the first file encountered keeps the key
|
||||
}
|
||||
exposes[key] = rel;
|
||||
used.add(key);
|
||||
}
|
||||
|
||||
return { exposes, program };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the installed version of a package.
|
||||
* @param {string} pkgName - The name of the package.
|
||||
* @param {string} projectRoot - The root directory of the project.
|
||||
* @returns {string|null} The installed version, or null if not found.
|
||||
*/
|
||||
function resolveInstalledVersion(pkgName, projectRoot) {
|
||||
try {
|
||||
const pkgJsonPath = require.resolve(`${pkgName}/package.json`, {
|
||||
paths: [projectRoot],
|
||||
});
|
||||
const { version } = require(pkgJsonPath);
|
||||
return version || null; // e.g. "18.3.1"
|
||||
} catch {
|
||||
return null; // not installed or cannot resolve
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically determines which dependencies to share based on the imports
|
||||
* of the exposed files.
|
||||
* @param {object} options - The options for making shared dependencies.
|
||||
* @param {ts.Program} options.program - The TypeScript program instance.
|
||||
* @param {Record<string, string>} options.exposes - The exposed modules.
|
||||
* @param {string[]} [options.include=[]] - A list of packages to include in sharing (e.g., 'react', 'styled-components').
|
||||
* @param {string[]} [options.exclude=[]] - A list of packages to exclude from sharing.
|
||||
* @returns {Record<string, object>} The shared dependencies configuration.
|
||||
*/
|
||||
function makeSharedAuto({ program, exposes, include = [], exclude = [] }) {
|
||||
const projectRoot = process.cwd();
|
||||
const { dependencies = {}, peerDependencies = {} } = require(
|
||||
path.join(projectRoot, 'package.json'),
|
||||
);
|
||||
const declared = { ...dependencies, ...peerDependencies };
|
||||
|
||||
const wanted = new Set(include);
|
||||
|
||||
const exposedFiles = new Set(
|
||||
Object.values(exposes).map((p) =>
|
||||
path.resolve(projectRoot, p.replace(/^\.\//, '')).replace(/\\/g, '/'),
|
||||
),
|
||||
);
|
||||
|
||||
// Analyze imports of exposed files to find dependencies to share
|
||||
for (const sf of program.getSourceFiles()) {
|
||||
const abs = sf.fileName.replace(/\\/g, '/');
|
||||
if (!exposedFiles.has(abs)) {
|
||||
continue;
|
||||
}
|
||||
const specs = (sf.imports || []).map((n) => n.text).filter(Boolean);
|
||||
for (const spec of specs) {
|
||||
if (isRelativeOrAlias(spec)) {
|
||||
continue;
|
||||
}
|
||||
wanted.add(pkgRootName(spec));
|
||||
}
|
||||
}
|
||||
|
||||
// Prune excluded packages
|
||||
for (const name of [...wanted]) {
|
||||
if (exclude.includes(name)) {
|
||||
wanted.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the shared object for Webpack
|
||||
return Object.fromEntries(
|
||||
[...wanted].map((name) => {
|
||||
// Prefer the actually installed version for 'requiredVersion'
|
||||
const resolved = resolveInstalledVersion(name, projectRoot);
|
||||
// Fallback to the version range from package.json if resolution fails
|
||||
const range = declared[name] || undefined;
|
||||
const requiredVersion = resolved || range; // Use concrete version when possible
|
||||
|
||||
return [
|
||||
name,
|
||||
{
|
||||
singleton: true,
|
||||
eager: false,
|
||||
requiredVersion,
|
||||
strictVersion: !!resolved, // Enforce exact match if we know the concrete version
|
||||
allowNodeModulesSuffixMatch: true,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a TypeScript diagnostic error into a readable string.
|
||||
* @param {string} context - A string describing the context of the error.
|
||||
* @param {ts.Diagnostic} diag - The TypeScript diagnostic object.
|
||||
* @returns {string} The formatted error message.
|
||||
*/
|
||||
function formatTsError(context, diag) {
|
||||
const message = ts.flattenDiagnosticMessageText(diag.messageText, '\n');
|
||||
return `${context}: ${message}`;
|
||||
}
|
||||
|
||||
// ---------- Final Module Federation Config ----------
|
||||
|
||||
// Define which folders should be scanned for exposable modules.
|
||||
const foldersToExpose = [
|
||||
'./src/components',
|
||||
'./src/features/auth',
|
||||
'./src/cunningham',
|
||||
];
|
||||
const { exposes, program } = makeExposes(foldersToExpose);
|
||||
|
||||
// Automatically determine shared dependencies based on the imports of exposed files.
|
||||
const shared = makeSharedAuto({
|
||||
program,
|
||||
exposes,
|
||||
include: ['react', 'react-dom', 'styled-components', 'yjs'],
|
||||
exclude: ['next'], // Packages to never share
|
||||
});
|
||||
|
||||
const moduleFederationConfig = {
|
||||
name: 'impress',
|
||||
filename: 'static/chunks/remoteEntry.js',
|
||||
extraOptions: { skipSharingNextInternals: true },
|
||||
|
||||
// The modules exposed by this federated module.
|
||||
exposes,
|
||||
|
||||
// The shared dependencies for this federated module.
|
||||
shared,
|
||||
|
||||
// TypeScript type generation for plugin development.
|
||||
// This is enabled when NEXT_PUBLIC_DEVELOP_PLUGINS is 'true'.
|
||||
dts:
|
||||
process.env.NEXT_PUBLIC_DEVELOP_PLUGINS === 'true'
|
||||
? {
|
||||
generateTypes: {
|
||||
extractThirdParty: true,
|
||||
tsConfigPath: './tsconfig.json',
|
||||
},
|
||||
}
|
||||
: false,
|
||||
};
|
||||
|
||||
module.exports = { moduleFederationConfig };
|
||||
@@ -1,9 +1,15 @@
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
NativeFederationTypeScriptRemote,
|
||||
} = require('@module-federation/native-federation-typescript/webpack');
|
||||
const { NextFederationPlugin } = require('@module-federation/nextjs-mf');
|
||||
const CopyPlugin = require('copy-webpack-plugin');
|
||||
const { InjectManifest } = require('workbox-webpack-plugin');
|
||||
|
||||
const { moduleFederationConfig } = require('./mf.config.js');
|
||||
|
||||
const buildId = crypto.randomBytes(256).toString('hex').slice(0, 8);
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
@@ -21,7 +27,18 @@ const nextConfig = {
|
||||
env: {
|
||||
NEXT_PUBLIC_BUILD_ID: buildId,
|
||||
},
|
||||
webpack(config, { isServer }) {
|
||||
webpack(config, { isServer, dev }) {
|
||||
// Prevent rebuild loops by ignoring node_modules and generated types/outputs
|
||||
config.watchOptions = {
|
||||
ignored: [
|
||||
'**/node_modules/**',
|
||||
'**/.next/**',
|
||||
'**/dist/**',
|
||||
'**/@mf-types/**',
|
||||
'**/@mf-types.zip',
|
||||
],
|
||||
};
|
||||
|
||||
// Grab the existing rule that handles SVG imports
|
||||
const fileLoaderRule = config.module.rules.find((rule) =>
|
||||
rule.test?.test?.('.svg'),
|
||||
@@ -67,23 +84,59 @@ const nextConfig = {
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isServer && process.env.NEXT_PUBLIC_SW_DEACTIVATED !== 'true') {
|
||||
config.plugins.push(
|
||||
new InjectManifest({
|
||||
swSrc: './src/features/service-worker/service-worker.ts',
|
||||
swDest: '../public/service-worker.js',
|
||||
include: [
|
||||
({ asset }) => {
|
||||
return !!asset.name.match(/.*(static).*/);
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (!isServer) {
|
||||
// Host configuration for Module Federation (PluginSystem)
|
||||
config.plugins.push(new NextFederationPlugin(moduleFederationConfig));
|
||||
if (dev && process.env.NEXT_PUBLIC_DEVELOP_PLUGINS === 'true') {
|
||||
console.log('[DEBUG] moduleFederationConfig:');
|
||||
console.log(moduleFederationConfig);
|
||||
|
||||
config.plugins.push(
|
||||
// Allow the plugin to get types/intellisense from the host at development time
|
||||
NativeFederationTypeScriptRemote({
|
||||
moduleFederationConfig,
|
||||
}),
|
||||
);
|
||||
|
||||
// Copy the generated @mf-types.zip to .next/static/chunks so it's served at /_next/static/chunks/
|
||||
const mfTypesSource = path.resolve(__dirname, '.next', '@mf-types.zip');
|
||||
const mfTypesDest = path.resolve(
|
||||
__dirname,
|
||||
'.next',
|
||||
'static',
|
||||
'chunks',
|
||||
'@mf-types.zip',
|
||||
);
|
||||
config.plugins.push(
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: mfTypesSource,
|
||||
to: mfTypesDest,
|
||||
force: true,
|
||||
noErrorOnMissing: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (process.env.NEXT_PUBLIC_SW_DEACTIVATED !== 'true') {
|
||||
config.plugins.push(
|
||||
new InjectManifest({
|
||||
swSrc: './src/features/service-worker/service-worker.ts',
|
||||
swDest: '../public/service-worker.js',
|
||||
include: [
|
||||
({ asset }) => {
|
||||
return !!asset.name.match(/.*(static).*/);
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Modify the file loader rule to ignore *.svg, since we have it handled now.
|
||||
fileLoaderRule.exclude = /\.svg$/i;
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "prettier --check . && yarn stylelint && next build",
|
||||
"dev": "NEXT_PRIVATE_LOCAL_WEBPACK=true next dev",
|
||||
"build": "prettier --check . && yarn stylelint && NEXT_PRIVATE_LOCAL_WEBPACK=true next build",
|
||||
"build:ci": "cp .env.development .env.local && yarn build",
|
||||
"build-theme": "cunningham -g css,ts -o src/cunningham --utility-classes && yarn prettier && yarn stylelint --fix",
|
||||
"start": "npx -y serve@latest out",
|
||||
@@ -35,6 +35,8 @@
|
||||
"@gouvfr-lasuite/integration": "1.0.3",
|
||||
"@gouvfr-lasuite/ui-kit": "0.16.1",
|
||||
"@hocuspocus/provider": "2.15.2",
|
||||
"@module-federation/runtime": "0.19.1",
|
||||
"@module-federation/runtime-core": "0.19.1",
|
||||
"@openfun/cunningham-react": "3.2.3",
|
||||
"@react-pdf/renderer": "4.3.0",
|
||||
"@sentry/nextjs": "10.11.0",
|
||||
@@ -67,6 +69,8 @@
|
||||
"zustand": "5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@module-federation/nextjs-mf": "8.8.42",
|
||||
"@module-federation/native-federation-typescript": "0.6.2",
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.87.4",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
@@ -93,7 +97,6 @@
|
||||
"typescript": "*",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "3.2.4",
|
||||
"webpack": "5.101.3",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Auth, KEY_AUTH, setAuthUrl } from '@/features/auth';
|
||||
import { useResponsiveStore } from '@/stores/';
|
||||
|
||||
import { ConfigProvider } from './config/';
|
||||
import { PluginSystemProvider } from './plugin/PluginSystemProvider';
|
||||
|
||||
export const DEFAULT_QUERY_RETRY = 1;
|
||||
|
||||
@@ -73,9 +74,11 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CunninghamProvider theme={theme}>
|
||||
<ConfigProvider>
|
||||
<Auth>{children}</Auth>
|
||||
</ConfigProvider>
|
||||
<PluginSystemProvider>
|
||||
<ConfigProvider>
|
||||
<Auth>{children}</Auth>
|
||||
</ConfigProvider>
|
||||
</PluginSystemProvider>
|
||||
</CunninghamProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { Resource } from 'i18next';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { PluginConfig } from '@/core/plugin';
|
||||
import { Theme } from '@/cunningham/';
|
||||
import { FooterType } from '@/features/footer';
|
||||
import { PostHogConf } from '@/services';
|
||||
@@ -26,6 +27,7 @@ export interface ConfigResponse {
|
||||
POSTHOG_KEY?: PostHogConf;
|
||||
SENTRY_DSN?: string;
|
||||
theme_customization?: ThemeCustomization;
|
||||
plugins?: PluginConfig[];
|
||||
}
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'docs_config';
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './AppProvider';
|
||||
export * from './config';
|
||||
export * from './plugin';
|
||||
|
||||
1222
src/frontend/apps/impress/src/core/plugin/PluginSystemProvider.tsx
Normal file
1222
src/frontend/apps/impress/src/core/plugin/PluginSystemProvider.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1
src/frontend/apps/impress/src/core/plugin/index.ts
Normal file
1
src/frontend/apps/impress/src/core/plugin/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './PluginSystemProvider';
|
||||
@@ -3,7 +3,7 @@ import { useRouter } from 'next/router';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useConfig } from '@/core';
|
||||
import { useConfig } from '@/core/config';
|
||||
|
||||
import { HOME_URL } from '../conf';
|
||||
import { useAuth } from '../hooks';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useConfig } from '@/core';
|
||||
import { useConfig } from '@/core/config';
|
||||
import { useIsOffline } from '@/features/service-worker';
|
||||
|
||||
import { KEY_CAN_EDIT, useDocCanEdit } from '../api/useDocCanEdit';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { DropdownMenu, Icon, Text } from '@/components/';
|
||||
import { useConfig } from '@/core';
|
||||
import { useConfig } from '@/core/config';
|
||||
import { useAuthQuery } from '@/features/auth';
|
||||
import {
|
||||
getMatchingLocales,
|
||||
|
||||
@@ -97,3 +97,8 @@ nextjs-portal {
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
/* PluginSystem utilities */
|
||||
[data-pluginsystem-hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
@@ -23,12 +24,6 @@
|
||||
"@/docs/*": ["./src/features/docs/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/custom-next.d.ts",
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"include": ["src/custom-next.d.ts", "next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
5620
src/frontend/bun.lock
Normal file
5620
src/frontend/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user