Integrates plugin system POC to test

... more fine grained commits later
This commit is contained in:
rvveber
2025-11-04 10:22:58 +01:00
parent dc25f3f39c
commit f0f2ca5edd
34 changed files with 11053 additions and 125 deletions
+2
View File
@@ -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 | |
+327
View File
@@ -0,0 +1,327 @@
## 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.
### 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**: Technology for runtime module sharing between apps.
- **Container**: Environment executing a remote module.
- **Exposed Module**: A module or component made available by a remote.
### Features and Limitations
**Features:**
- Add new UI components.
- Reuse host UI components.
- Dynamically inject via CSS selectors and config.
- Integrate without rebuilding or redeploying the host.
- Build and version plugins independently.
**Limitations:**
- Primarily for UI additions/modifications.
- Libraries used, that the host already provides, should match host versions to allow singleton behavior.
- Cannot extend Next.js-specific features like pages.
- No direct access to host state (only few exceptions where a global intermediary cache is used e.g. react-queries).
- Host updates may require plugin checks/updates for DOM/component changes (usually minimal).
### Overview
This diagram shows the plugin integration flow: fetching config and plugins, checking visibility, starting DOM observation, conditionally rendering components, and re-checking on DOM changes.
```mermaid
flowchart TD
subgraph Host
A["Fetches Config<br>(default.json)"] --> B["Fetches Plugins<br>(remoteEntry.js)"]
B --> C["Checks Visibility<br>(route matches)"]
C --> F["Checks if Targets exist<br>(CSS selector)"]
F --> G["Checks if Component already injected"]
G --> D["Render Component with Props into Targets"]
D --> H["Start DOM Observer<br>(if enabled)"]
H -.-> C
end
```
## Configuration
Plugins are configured via a JSON file (e.g., `impress/configuration/plugins/default.json`) loaded at runtime by the host. Place it in the backend, via a Docker volume for single-file drop-in or Kubernetes ConfigMap.
### 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`). |
| - `observerRoots` | String/Boolean | No | DOM observation: CSS selector, `true` (full page), `false` (none). |
| `props` | Object | No | Props passed to the plugin component. |
| `visibility` | Object | No | Visibility controls. |
| - `routes` | Array | No | Path globs (e.g., ["/docs/*", "!/docs/secret*"]); supports negation (`!`). |
### Example
```json
{
"id": "my-custom-component-1",
"remote": {
"url": "localhost:3001/remoteEntry.js",
"name": "my-plugin",
"module": "./MyCustomComponent"
},
"injection": {
"target": "#list #item3",
"position": "append",
"observerRoots": "#list"
},
"props": {
"title": "My Widget",
"color": "#ffcc00"
},
"visibility": {
"routes": ["/docs/*", "!/docs/secret*"]
}
}
```
### Key Notes
- `remote` and `injection` are required.
- `remote.url` can be relative if the plugins compiled `remoteEntry.js` is placed in the hosts public folder (e.g. via k8s ConfigMap)
```diff
- "url": "http://localhost:3001/remoteEntry.js",
+ "url": "/plugins/my-plugin/remoteEntry.js",
```
- Use `target`/`position` for flexible placement (e.g., replace or append).
- `observerRoots` enables re-injection if target lost through DOM changes<br>(e.g., components re-rendering/un-mounting)<br>It should be a selector to the closest stable ancestor<br>(e.g. `"#list"` if list is a dynamically populated container).
- Restrict visibility with `routes` globs and negations.
- Pass custom data via `props`.
#### `injection.position`
Below are simple examples for all possible values.<br>
Each shows the relevant JSON config and the resulting HTML structure after injection:
**before**
```json
{
"injection": {
"target": "#list #item3",
"position": "before",
"observerRoots": "#list"
}
}
```
```html
<ul id="list">
<li id="item1"></li>
<li id="item2"></li>
<div id="plugin-container-my-custom-component-0"></div>
<li id="item3"></li>
</ul>
```
**after**
```json
{
"injection": {
"target": "#list #item1",
"position": "after",
"observerRoots": "#list"
}
}
```
```html
<ul id="list">
<li id="item1"></li>
<div id="plugin-container-my-custom-component-0"></div>
<li id="item2"></li>
<li id="item3"></li>
</ul>
```
**prepend**
```json
{
"injection": {
"target": "#list",
"position": "prepend",
"observerRoots": "#list"
}
}
```
```html
<ul id="list">
<div id="plugin-container-my-custom-component-0"></div>
<li id="item1"></li>
<li id="item2"></li>
<li id="item3"></li>
</ul>
```
**append**
```json
{
"injection": {
"target": "#list",
"position": "append",
"observerRoots": "#list"
}
}
```
```html
<ul id="list">
<li id="item1"></li>
<li id="item2"></li>
<li id="item3"></li>
<div id="plugin-container-my-custom-component-0"></div>
</ul>
```
**replace**
```json
{
"injection": {
"target": "#list #item2",
"position": "replace",
"observerRoots": "#list"
}
}
```
```html
<ul id="list">
<li id="item1"></li>
<div id="plugin-container-my-custom-component-0"></div>
<li id="item2" data-pluginsystem-hidden="true"></li>
<li id="item3"></li>
</ul>
```
## Development Guide
### Environment Variables
Set `NEXT_PUBLIC_DEVELOP_PLUGINS=true` in `.env.development` for debug logs, type-sharing, and hot-reload support. This also logs all exposed modules with exact versions on startup, helping match plugin dependencies.
### Type-Sharing for Intellisense
In plugin `tsconfig.json`:
```json
{
"baseUrl": ".",
"paths": {
"*": ["./@mf-types/*"]
}
}
```
Types update on build for compatibility and autocompletion.
### Exports Support
The host automatically exposes components and some features under the same structure that is used in docs code.
```typescript
// Direct import
import { useAuthQuery } from 'impress/features/auth/api/useAuthQuery';
import { Icon } from 'impress/components/Icon';
// Clean barrel export import
import { useAuthQuery } from 'impress/features/auth/api';
import { Icon } from 'impress/components';
```
**Important Notes:**
- Only barrel exports with runtime values are exposed (not type-only exports)
- In case of naming conflicts (e.g., `Button.tsx` and `Button/index.tsx`), explicit files take precedence over barrel exports
- The host logs warnings for any naming conflicts during build
### Recommended Workflow
2. Enable NEXT_PUBLIC_DEVELOP_PLUGINS and start host(docs) & plugin dev servers in parallel.
3. Configure federation in plugin `webpack.config.js` and expose components (see Examples).
4. Develop with hot-reload; use host components via shared types.
5. Test in host via config; debug with logs.
6. Version and deploy independently.
### Integration in Host
1. Build plugin and host `remoteEntry.js`.
2. Add to host config JSON.
3. Start host; plugin loads at runtime.
4. Verify via `[PluginSystem]` logs.
### Debugging
Enable `NEXT_PUBLIC_DEVELOP_PLUGINS=true` for console logs like `[PluginSystem] ...`.
Common Errors:
| Issue | Cause/Fix |
|------------------------|-----------|
| Unreachable `remoteEntry.js` | Check URL; ensure accessible. |
| Library version conflicts | Match React/etc. versions; use singletons in federation. |
| Invalid CSS selectors | Validate `target` against host DOM. |
| Type mismatches | Update shared types on build. |
Errors are isolated via host ErrorBoundary.
## Best Practices and Security
### Best Practices
- Keep components modular and self-contained.
- Use props for flexibility/reusability.
- Document interfaces and expected props.
- Version plugins for host compatibility.
- Avoid internal host dependencies; use official types/components.
- Test regularly in host; monitor logs.
- Update federation config with host changes.
### Security and Isolation
- Plugins run isolated with ErrorBoundaries; errors don't crash host.
- No direct host state access; use interfaces/props.
- Vet third-party libraries for security.
- Check plugin functionality after host updates.
- Avoid unsafe external scripts/resources.
## Examples
### Federation Configuration (webpack.config.js)
Define `moduleFederationConfig` first for reuse:
```js
const { ModuleFederationPlugin } = require('webpack').container;
const NativeFederationTypeScriptHost = require('@module-federation/native-federation-typescript/host');
const moduleFederationConfig = {
name: 'my-plugin',
filename: 'remoteEntry.js',
exposes: {
'./MyCustomComponent': './src/MyCustomComponent.tsx',
},
remotes: {
impress: 'impress@http://localhost:3000/_next/static/chunks/remoteEntry.js',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
'styled-components': { singleton: true },
'cunningham-react': { singleton: true },
},
};
module.exports = {
plugins: [
new ModuleFederationPlugin(moduleFederationConfig),
...(dev ? [NativeFederationTypeScriptHost({ moduleFederationConfig })] : []),
]
};
```
Adjust names/paths; declare shared libs as singletons. Use relative remotes if the plugin `remoteEntry.js` lives in host's public folder (e.g., public/plugins/my-plugin/remoteEntry.js).
```diff
- impress: 'impress@localhost:3000/_next/static/chunks/remoteEntry.js'
+ impress: 'impress@/_next/static/chunks/remoteEntry.js',
```
For plugin config example, see Configuration section.
## Summary
The plugin system enables runtime frontend extensions via module federation, with easy config, type-sharing, and independent deployment. Focus on UI mods, match versions, and test for compatibility. Use the diagram, tables, and examples for quick reference.
+4
View File
@@ -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
+42
View File
@@ -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
@@ -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
}
}
]
+12
View File
@@ -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
@@ -0,0 +1,2 @@
@mf-types/
node_modules/
@@ -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
@@ -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"
}
}
@@ -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' }}>
Loading plugin data...
</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;
@@ -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;
}
@@ -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;
@@ -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;
@@ -0,0 +1,164 @@
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 tokens.
Any component using these tokens will update!
</div>
</div>
</>
);
};
export default ThemingDemo;
@@ -0,0 +1,2 @@
// This file is just to satisfy webpack's entry point requirement
// The actual component is exposed via Module Federation
@@ -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"]
}
@@ -0,0 +1,80 @@
const path = require('path');
const { NativeFederationTypeScriptHost } = require('@module-federation/native-federation-typescript/webpack');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
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 },
},
};
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
? [
NativeFederationTypeScriptHost({
moduleFederationConfig,
}),
]
: []),
],
};
};
+3 -3
View File
@@ -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
View 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 };
+61 -14
View File
@@ -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,12 @@ const nextConfig = {
env: {
NEXT_PUBLIC_BUILD_ID: buildId,
},
webpack(config, { isServer }) {
webpack(config, { isServer, dev }) {
// Prevent rebuild loops by ignoring node_modules and build outputs
config.watchOptions = {
ignored: ['**/node_modules/**', '**/.next/**', '**/dist/**'],
};
// Grab the existing rule that handles SVG imports
const fileLoaderRule = config.module.rules.find((rule) =>
rule.test?.test?.('.svg'),
@@ -67,23 +78,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 -3
View File
@@ -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 -1
View File
@@ -1,2 +1,2 @@
export * from './AppProvider';
export * from './config';
export * from './plugin';
File diff suppressed because it is too large Load Diff
@@ -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 -1
View File
@@ -1,5 +1,6 @@
{
"compilerOptions": {
"baseUrl": ".",
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
@@ -28,7 +29,6 @@
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
}
File diff suppressed because it is too large Load Diff
+1954 -97
View File
File diff suppressed because it is too large Load Diff