mfeflow 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/features/analytics-clarity/feature.json +64 -0
- package/assets/features/analytics-clarity/files/clarity-test.ts +36 -0
- package/assets/features/analytics-clarity/files/clarity.ts +26 -0
- package/assets/features/analytics-gtm/files/init.ts +2 -2
- package/assets/features/ci-github/files/ci.md +1 -1
- package/assets/features/ci-github/files/ci.yml +4 -4
- package/assets/features/commit-prompt/feature.json +27 -0
- package/assets/features/commit-prompt/files/cz.config.js +27 -0
- package/assets/features/data-react-query/feature.json +89 -0
- package/assets/features/data-react-query/files/keys.ts.eta +8 -0
- package/assets/features/data-react-query/files/remote-Query-test.tsx.eta +39 -0
- package/assets/features/data-react-query/files/remote-Query.tsx +15 -0
- package/assets/features/data-react-query/files/shell-Query-test.tsx +21 -0
- package/assets/features/data-react-query/files/shell-Query.tsx +14 -0
- package/assets/features/data-react-query/files/useHealth.fetch.ts.eta +15 -0
- package/assets/features/data-react-query/files/useHealth.http.ts.eta +11 -0
- package/assets/features/env-t3/feature.json +42 -0
- package/assets/features/env-t3/files/env.arktype.ts +1 -1
- package/assets/features/env-t3/files/env.valibot.ts +1 -1
- package/assets/features/env-t3/files/env.zod.ts +1 -1
- package/assets/features/env-t3/files/schema.arktype.ts +1 -1
- package/assets/features/env-t3/files/schema.valibot.ts +1 -1
- package/assets/features/env-t3/files/schema.zod.ts +1 -1
- package/assets/features/event-bus/feature.json +100 -0
- package/assets/features/event-bus/files/announce.tsx.eta +10 -0
- package/assets/features/event-bus/files/contracts-events.ts +11 -0
- package/assets/features/event-bus/files/contracts-package.json.eta +10 -0
- package/assets/features/event-bus/files/contracts-readme.md +10 -0
- package/assets/features/event-bus/files/events-test.ts.eta +18 -0
- package/assets/features/event-bus/files/events.ts.eta +20 -0
- package/assets/features/event-bus/files/shell-events-test.ts.eta +22 -0
- package/assets/features/event-bus/files/shell-events.ts.eta +19 -0
- package/assets/features/forms/feature.json +90 -0
- package/assets/features/forms/files/ContactForm-test.tsx +35 -0
- package/assets/features/forms/files/ContactForm.tsx +50 -0
- package/assets/features/forms/files/schema.arktype.ts +9 -0
- package/assets/features/forms/files/schema.valibot.ts +9 -0
- package/assets/features/forms/files/schema.zod.ts +9 -0
- package/assets/features/http/feature.json +81 -0
- package/assets/features/http/files/client-test.ts +19 -0
- package/assets/features/http/files/client.ts +20 -0
- package/assets/features/i18n/feature.json +139 -0
- package/assets/features/i18n/files/LanguageSwitcher.tsx +28 -0
- package/assets/features/i18n/files/Languages.tsx +12 -0
- package/assets/features/i18n/files/language-switcher-test.tsx +19 -0
- package/assets/features/i18n/files/remote-I18n-test.tsx.eta +43 -0
- package/assets/features/i18n/files/remote-I18n.tsx.eta +36 -0
- package/assets/features/i18n/files/remote-ar.json.eta +3 -0
- package/assets/features/i18n/files/remote-config.ts +9 -0
- package/assets/features/i18n/files/remote-en.json.eta +3 -0
- package/assets/features/i18n/files/remote-load.ts +4 -0
- package/assets/features/i18n/files/shell-config.ts +20 -0
- package/assets/features/i18n/files/shell-init-test.ts +32 -0
- package/assets/features/i18n/files/shell-init.ts +7 -0
- package/assets/features/lint-oxlint/files/oxlintrc.json +1 -1
- package/assets/features/lint-oxlint/files/prettierignore +1 -0
- package/assets/features/route-factory/feature.json +60 -0
- package/assets/features/route-factory/files/contract.ts.eta +7 -0
- package/assets/features/route-factory/files/routes-contract.ts.eta +11 -0
- package/assets/features/route-factory/files/routes-test.ts.eta +9 -0
- package/assets/features/route-factory/files/routes.ts.eta +11 -0
- package/assets/features/storybook/feature.json +119 -0
- package/assets/features/storybook/files/App.stories.tsx.eta +8 -0
- package/assets/features/storybook/files/Home.stories.tsx +9 -0
- package/assets/features/storybook/files/button.stories.tsx +14 -0
- package/assets/features/storybook/files/remote-main.ts +13 -0
- package/assets/features/storybook/files/rsbuild.config.ts +10 -0
- package/assets/features/storybook/files/shell-main.ts +39 -0
- package/assets/features/tailwind/feature.json +87 -0
- package/assets/features/tailwind/files/Scope-test.tsx.eta +17 -0
- package/assets/features/tailwind/files/Scope.tsx.eta +15 -0
- package/assets/features/tailwind/files/postcss.config.mjs +1 -0
- package/assets/features/tailwind/files/remote-styles.css.eta +21 -0
- package/assets/features/tailwind/files/shell-styles.css.eta +22 -0
- package/assets/features/testing/files/vitest.config.ts +11 -2
- package/assets/features/theming/feature.json +103 -0
- package/assets/features/theming/files/Theme.tsx +15 -0
- package/assets/features/theming/files/ThemeToggle.tsx +23 -0
- package/assets/features/theming/files/apply-stored-test.ts +37 -0
- package/assets/features/theming/files/apply-stored.ts +13 -0
- package/assets/features/theming/files/head-script.ts +8 -0
- package/assets/features/theming/files/theme-toggle-test.tsx +18 -0
- package/assets/features/theming/files/theme.css +14 -0
- package/assets/features/theming/files/themes.ts +30 -0
- package/assets/features/theming/files/tokens-test.ts +9 -0
- package/assets/features/theming/files/tokens.ts +9 -0
- package/assets/features/ui-shadcn/feature.json +61 -0
- package/assets/features/ui-shadcn/files/button-test.tsx.eta +32 -0
- package/assets/features/ui-shadcn/files/button.tsx.eta +36 -0
- package/assets/features/ui-shadcn/files/components.json.eta +22 -0
- package/assets/features/ui-shadcn/files/utils-test.ts.eta +17 -0
- package/assets/features/ui-shadcn/files/utils.ts.eta +11 -0
- package/assets/features/url-factory/files/endpoints.ts +13 -2
- package/assets/templates/monorepo/feature.json +26 -0
- package/assets/templates/monorepo/files/root/AGENTS.md.eta +3 -3
- package/assets/templates/monorepo/files/root/README.md.eta +11 -11
- package/assets/templates/monorepo/files/root/gitignore.eta +1 -0
- package/assets/templates/monorepo/files/root/pnpm-workspace.yaml.eta +3 -1
- package/assets/templates/monorepo/files/shell/src/pages/Home.tsx.eta +3 -1
- package/dist/index.js +62 -0
- package/package.json +4 -4
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<%_ const prefix = it.app.tailwindPrefix _%>
|
|
2
|
+
/* The shell is the only app that loads Tailwind's reset (preflight). */
|
|
3
|
+
@layer theme, base, components, utilities;
|
|
4
|
+
@import 'tailwindcss/theme.css' layer(theme) prefix(<%= prefix %>);
|
|
5
|
+
@import 'tailwindcss/preflight.css' layer(base);
|
|
6
|
+
@import 'tailwindcss/utilities.css' layer(utilities) prefix(<%= prefix %>);
|
|
7
|
+
|
|
8
|
+
/* Tokens read the theme variables (--mf-*). The second value is used when there is no theme. */
|
|
9
|
+
@theme inline {
|
|
10
|
+
--color-background: var(--mf-color-bg, #ffffff);
|
|
11
|
+
--color-foreground: var(--mf-color-fg, #111827);
|
|
12
|
+
--color-surface: var(--mf-color-surface, #f3f4f6);
|
|
13
|
+
--color-primary: var(--mf-color-primary, #2563eb);
|
|
14
|
+
--color-primary-foreground: var(--mf-color-primary-fg, #ffffff);
|
|
15
|
+
--color-secondary: var(--mf-color-surface, #f3f4f6);
|
|
16
|
+
--color-secondary-foreground: var(--mf-color-fg, #111827);
|
|
17
|
+
--color-accent: var(--mf-color-surface, #f3f4f6);
|
|
18
|
+
--color-accent-foreground: var(--mf-color-fg, #111827);
|
|
19
|
+
--color-destructive: #dc2626;
|
|
20
|
+
--color-border: var(--mf-color-border, #e5e7eb);
|
|
21
|
+
--color-ring: var(--mf-color-primary, #2563eb);
|
|
22
|
+
}
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { defineConfig } from 'vitest/config';
|
|
2
2
|
|
|
3
3
|
export default defineConfig({
|
|
4
|
-
// One copy of React and Testing Library, also when a package like @mfeflow/testing is linked from another folder.
|
|
5
|
-
resolve: {
|
|
4
|
+
// One copy of React, i18next and Testing Library, also when a package like @mfeflow/testing is linked from another folder.
|
|
5
|
+
resolve: {
|
|
6
|
+
dedupe: [
|
|
7
|
+
'react',
|
|
8
|
+
'react-dom',
|
|
9
|
+
'react-i18next',
|
|
10
|
+
'i18next',
|
|
11
|
+
'@testing-library/react',
|
|
12
|
+
'@testing-library/dom',
|
|
13
|
+
],
|
|
14
|
+
},
|
|
6
15
|
test: {
|
|
7
16
|
environment: 'jsdom',
|
|
8
17
|
include: ['src/**/*.test.{ts,tsx}'],
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "theming",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Light and dark themes as CSS variables (@mfeflow/theme): the shell owns the provider and the persisted choice, every remote reads the same --mf-* tokens with no theme code of its own. The stored theme is set by an inline script in the shell's <head>, before any bundle loads, so a reload never paints the wrong colours.",
|
|
5
|
+
"requires": ["core", "tsconfig"],
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"to": "src/theme/themes.ts",
|
|
9
|
+
"target": "shell",
|
|
10
|
+
"from": "files/themes.ts"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"to": "src/theme/Theme.tsx",
|
|
14
|
+
"target": "shell",
|
|
15
|
+
"from": "files/Theme.tsx"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"to": "src/theme/ThemeToggle.tsx",
|
|
19
|
+
"target": "shell",
|
|
20
|
+
"from": "files/ThemeToggle.tsx"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"to": "src/theme/theme.css",
|
|
24
|
+
"target": "shell",
|
|
25
|
+
"from": "files/theme.css"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"to": "src/theme/apply-stored.ts",
|
|
29
|
+
"target": "shell",
|
|
30
|
+
"from": "files/apply-stored.ts"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"to": "src/theme/head-script.ts",
|
|
34
|
+
"target": "shell",
|
|
35
|
+
"from": "files/head-script.ts"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"to": "src/theme/apply-stored.test.ts",
|
|
39
|
+
"target": "shell",
|
|
40
|
+
"from": "files/apply-stored-test.ts",
|
|
41
|
+
"when": {
|
|
42
|
+
"feature:testing": true
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"to": "src/theme/ThemeToggle.test.tsx",
|
|
47
|
+
"target": "shell",
|
|
48
|
+
"from": "files/theme-toggle-test.tsx",
|
|
49
|
+
"when": {
|
|
50
|
+
"feature:testing": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"to": "src/theme/tokens.ts",
|
|
55
|
+
"target": "remotes",
|
|
56
|
+
"from": "files/tokens.ts"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"to": "src/theme/tokens.test.ts",
|
|
60
|
+
"target": "remotes",
|
|
61
|
+
"from": "files/tokens-test.ts",
|
|
62
|
+
"when": {
|
|
63
|
+
"feature:testing": true
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
],
|
|
67
|
+
"dependencies": [
|
|
68
|
+
{
|
|
69
|
+
"name": "@mfeflow/theme",
|
|
70
|
+
"range": "latest",
|
|
71
|
+
"target": "shell"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"name": "@mfeflow/theme",
|
|
75
|
+
"range": "latest",
|
|
76
|
+
"target": "remotes"
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
"transforms": [
|
|
80
|
+
{
|
|
81
|
+
"op": "add-import",
|
|
82
|
+
"file": "rsbuild.config.ts",
|
|
83
|
+
"target": "shell",
|
|
84
|
+
"from": "./src/theme/head-script"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"op": "add-boot-step",
|
|
88
|
+
"file": "src/bootstrap.tsx",
|
|
89
|
+
"target": "shell",
|
|
90
|
+
"call": "applyStoredTheme",
|
|
91
|
+
"from": "./theme/apply-stored",
|
|
92
|
+
"await": false
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"op": "wrap-provider",
|
|
96
|
+
"file": "src/App.tsx",
|
|
97
|
+
"target": "shell",
|
|
98
|
+
"component": "Theme",
|
|
99
|
+
"from": "./theme/Theme",
|
|
100
|
+
"function": "App"
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { ThemeProvider } from '@mfeflow/theme/react';
|
|
3
|
+
import { ThemeToggle } from './ThemeToggle';
|
|
4
|
+
import { defaultTheme, followSystem, themes } from './themes';
|
|
5
|
+
import './theme.css';
|
|
6
|
+
|
|
7
|
+
/** Wraps the shell. The choice is stored under the shell's storage, so every tab and reload agrees. */
|
|
8
|
+
export function Theme({ children }: { children: ReactNode }) {
|
|
9
|
+
return (
|
|
10
|
+
<ThemeProvider themes={themes} defaultTheme={defaultTheme} followSystem={followSystem}>
|
|
11
|
+
{children}
|
|
12
|
+
<ThemeToggle />
|
|
13
|
+
</ThemeProvider>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useTheme } from '@mfeflow/theme/react';
|
|
2
|
+
|
|
3
|
+
/** Your component: move it into the header, restyle it, or swap it for a menu. */
|
|
4
|
+
export function ThemeToggle() {
|
|
5
|
+
const { theme, themes, setTheme } = useTheme();
|
|
6
|
+
return (
|
|
7
|
+
<label className="theme-toggle">
|
|
8
|
+
Theme{' '}
|
|
9
|
+
<select
|
|
10
|
+
value={theme.name}
|
|
11
|
+
onChange={(event) => {
|
|
12
|
+
setTheme(event.target.value);
|
|
13
|
+
}}
|
|
14
|
+
>
|
|
15
|
+
{themes.map((name) => (
|
|
16
|
+
<option key={name} value={name}>
|
|
17
|
+
{name}
|
|
18
|
+
</option>
|
|
19
|
+
))}
|
|
20
|
+
</select>
|
|
21
|
+
</label>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { applyStoredTheme, currentStartingTheme } from './apply-stored';
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
localStorage.clear();
|
|
6
|
+
document.documentElement.removeAttribute('data-theme');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe('applyStoredTheme', () => {
|
|
10
|
+
it('uses the default theme when nothing is stored', () => {
|
|
11
|
+
expect(currentStartingTheme().name).toBe('light');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('puts the stored theme on <html> with its variables', () => {
|
|
15
|
+
localStorage.setItem('shell:theme', JSON.stringify('dark'));
|
|
16
|
+
applyStoredTheme();
|
|
17
|
+
expect(document.documentElement.dataset.theme).toBe('dark');
|
|
18
|
+
expect(document.documentElement.style.getPropertyValue('--mf-color-bg')).not.toBe('');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('ignores a stored value that is not a theme', () => {
|
|
22
|
+
localStorage.setItem('shell:theme', '{broken');
|
|
23
|
+
expect(currentStartingTheme().name).toBe('light');
|
|
24
|
+
localStorage.setItem('shell:theme', JSON.stringify('purple'));
|
|
25
|
+
expect(currentStartingTheme().name).toBe('light');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it.each(['constructor', '__proto__', 'toString'])(
|
|
29
|
+
'ignores a stored name that only exists on every object: %s',
|
|
30
|
+
(name) => {
|
|
31
|
+
localStorage.setItem('shell:theme', JSON.stringify(name));
|
|
32
|
+
expect(currentStartingTheme().name).toBe('light');
|
|
33
|
+
applyStoredTheme();
|
|
34
|
+
expect(document.documentElement.dataset.theme).toBe('light');
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { applyTheme, type Theme } from '@mfeflow/theme';
|
|
2
|
+
import { startingTheme } from '@mfeflow/theme/starting';
|
|
3
|
+
import { defaultTheme, followSystem, themes } from './themes';
|
|
4
|
+
|
|
5
|
+
/** The theme to show before React renders: the stored choice, else the system preference (if followSystem), else the default. */
|
|
6
|
+
export function currentStartingTheme(): Theme {
|
|
7
|
+
return startingTheme(themes, { defaultTheme, followSystem });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The first boot step: the variables are on <html> before the first render, also when the head script was blocked. */
|
|
11
|
+
export function applyStoredTheme(): void {
|
|
12
|
+
applyTheme(currentStartingTheme());
|
|
13
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { registerHeadScript } from '@mfeflow/rsbuild';
|
|
2
|
+
import { noFlashScript } from '@mfeflow/theme';
|
|
3
|
+
import { defaultTheme, followSystem, themes } from './themes';
|
|
4
|
+
|
|
5
|
+
// Imported by rsbuild.config.ts. The script goes first in the shell's <head>, so the stored theme is on <html>
|
|
6
|
+
// before any bundle has loaded. It needs the CSP to allow an inline script (a nonce or 'unsafe-inline');
|
|
7
|
+
// without that, apply-stored.ts still sets the theme before the first render.
|
|
8
|
+
registerHeadScript('theme', noFlashScript(themes, { defaultTheme, followSystem }));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { fireEvent, screen } from '@testing-library/react';
|
|
2
|
+
import { renderWithPlatform } from '@mfeflow/testing';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { Theme } from './Theme';
|
|
5
|
+
|
|
6
|
+
describe('Theme', () => {
|
|
7
|
+
it('switches the theme on <html> and remembers it', () => {
|
|
8
|
+
renderWithPlatform(
|
|
9
|
+
<Theme>
|
|
10
|
+
<p>content</p>
|
|
11
|
+
</Theme>,
|
|
12
|
+
);
|
|
13
|
+
expect(document.documentElement.dataset.theme).toBe('light');
|
|
14
|
+
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'dark' } });
|
|
15
|
+
expect(document.documentElement.dataset.theme).toBe('dark');
|
|
16
|
+
expect(localStorage.getItem('shell:theme')).toBe('"dark"');
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
html {
|
|
2
|
+
background: var(--mf-color-bg, #ffffff);
|
|
3
|
+
color: var(--mf-color-fg, #111827);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.theme-toggle {
|
|
7
|
+
position: fixed;
|
|
8
|
+
right: 1rem;
|
|
9
|
+
bottom: 1rem;
|
|
10
|
+
padding: 0.25rem 0.5rem;
|
|
11
|
+
border-radius: 0.5rem;
|
|
12
|
+
background: var(--mf-color-surface, #f3f4f6);
|
|
13
|
+
color: var(--mf-color-fg, #111827);
|
|
14
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defineTheme } from '@mfeflow/theme';
|
|
2
|
+
|
|
3
|
+
// Every token becomes a --mf-<token> variable on <html>. Remotes read them with var(--mf-color-fg).
|
|
4
|
+
export const themes = [
|
|
5
|
+
defineTheme('light', {
|
|
6
|
+
'color-bg': '#ffffff',
|
|
7
|
+
'color-fg': '#111827',
|
|
8
|
+
'color-surface': '#f3f4f6',
|
|
9
|
+
'color-primary': '#2563eb',
|
|
10
|
+
'color-primary-fg': '#ffffff',
|
|
11
|
+
'color-border': '#e5e7eb',
|
|
12
|
+
}),
|
|
13
|
+
defineTheme(
|
|
14
|
+
'dark',
|
|
15
|
+
{
|
|
16
|
+
'color-bg': '#0b1020',
|
|
17
|
+
'color-fg': '#e5e7eb',
|
|
18
|
+
'color-surface': '#1f2937',
|
|
19
|
+
'color-primary': '#60a5fa',
|
|
20
|
+
'color-primary-fg': '#0b1020',
|
|
21
|
+
'color-border': '#374151',
|
|
22
|
+
},
|
|
23
|
+
{ colorScheme: 'dark' },
|
|
24
|
+
),
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export const defaultTheme = 'light';
|
|
28
|
+
|
|
29
|
+
// Use the system dark-mode setting when no theme is stored. The provider, the head script and the boot step all read this.
|
|
30
|
+
export const followSystem = true;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { tokens } from './tokens';
|
|
3
|
+
|
|
4
|
+
describe('tokens', () => {
|
|
5
|
+
it('are the shell variables, with a fallback for standalone runs', () => {
|
|
6
|
+
expect(tokens.primary).toBe('var(--mf-color-primary, #2563eb)');
|
|
7
|
+
expect(tokens.fg).toContain('--mf-color-fg');
|
|
8
|
+
});
|
|
9
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { cssVar } from '@mfeflow/theme';
|
|
2
|
+
|
|
3
|
+
// The shell sets these on <html>. The second argument is what a standalone run (no shell) falls back to.
|
|
4
|
+
export const tokens = {
|
|
5
|
+
bg: cssVar('color-bg', '#ffffff'),
|
|
6
|
+
fg: cssVar('color-fg', '#111827'),
|
|
7
|
+
surface: cssVar('color-surface', '#f3f4f6'),
|
|
8
|
+
primary: cssVar('color-primary', '#2563eb'),
|
|
9
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ui-shadcn",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "shadcn/ui in every remote, on top of tailwind: components.json, the cn helper and a Button, copied into the remote so you own them. The classes carry the remote's Tailwind prefix and the colours come from the theme. Nothing is shared between remotes.",
|
|
5
|
+
"requires": ["core", "tsconfig", "tailwind"],
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"to": "components.json",
|
|
9
|
+
"target": "remotes",
|
|
10
|
+
"from": "files/components.json.eta",
|
|
11
|
+
"template": true
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"to": "src/lib/utils.ts",
|
|
15
|
+
"target": "remotes",
|
|
16
|
+
"from": "files/utils.ts.eta",
|
|
17
|
+
"template": true
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"to": "src/components/ui/button.tsx",
|
|
21
|
+
"target": "remotes",
|
|
22
|
+
"from": "files/button.tsx.eta",
|
|
23
|
+
"template": true
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"to": "src/lib/utils.test.ts",
|
|
27
|
+
"target": "remotes",
|
|
28
|
+
"from": "files/utils-test.ts.eta",
|
|
29
|
+
"template": true,
|
|
30
|
+
"when": {
|
|
31
|
+
"feature:testing": true
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"to": "src/components/ui/button.test.tsx",
|
|
36
|
+
"target": "remotes",
|
|
37
|
+
"from": "files/button-test.tsx.eta",
|
|
38
|
+
"template": true,
|
|
39
|
+
"when": {
|
|
40
|
+
"feature:testing": true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"dependencies": [
|
|
45
|
+
{
|
|
46
|
+
"name": "class-variance-authority",
|
|
47
|
+
"range": "0.7.1",
|
|
48
|
+
"target": "remotes"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "clsx",
|
|
52
|
+
"range": "2.1.1",
|
|
53
|
+
"target": "remotes"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"name": "tailwind-merge",
|
|
57
|
+
"range": "3.7.0",
|
|
58
|
+
"target": "remotes"
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<%_ const prefix = it.app.tailwindPrefix _%>
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { Button } from './button';
|
|
5
|
+
|
|
6
|
+
const prefix = '<%= prefix %>';
|
|
7
|
+
|
|
8
|
+
describe('Button', () => {
|
|
9
|
+
it('is a button that does not submit a form unless asked', () => {
|
|
10
|
+
render(<Button>Save</Button>);
|
|
11
|
+
expect(screen.getByRole('button', { name: 'Save' }).getAttribute('type')).toBe('button');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('carries the remote prefix on its classes, and lets a caller override one', () => {
|
|
15
|
+
render(<Button className={`${prefix}:px-8`}>Save</Button>);
|
|
16
|
+
const { className } = screen.getByRole('button');
|
|
17
|
+
expect(className).toContain(`${prefix}:bg-primary`);
|
|
18
|
+
expect(className).toContain(`${prefix}:px-8`);
|
|
19
|
+
expect(className).not.toContain(`${prefix}:px-4`);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('does not click while disabled', () => {
|
|
23
|
+
const onClick = vi.fn();
|
|
24
|
+
render(
|
|
25
|
+
<Button disabled onClick={onClick}>
|
|
26
|
+
Save
|
|
27
|
+
</Button>,
|
|
28
|
+
);
|
|
29
|
+
fireEvent.click(screen.getByRole('button'));
|
|
30
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<%_ const prefix = it.app.tailwindPrefix _%>
|
|
2
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
3
|
+
import type { ButtonHTMLAttributes } from 'react';
|
|
4
|
+
import { cn } from '../../lib/utils';
|
|
5
|
+
|
|
6
|
+
// Every class starts with <%= prefix %>: (this remote's Tailwind prefix). Colours are the theme tokens from src/styles.css.
|
|
7
|
+
// prettier-ignore
|
|
8
|
+
const buttonVariants = cva(
|
|
9
|
+
'<%= prefix %>:inline-flex <%= prefix %>:items-center <%= prefix %>:justify-center <%= prefix %>:gap-2 <%= prefix %>:rounded-md <%= prefix %>:text-sm <%= prefix %>:font-medium <%= prefix %>:transition-colors <%= prefix %>:disabled:pointer-events-none <%= prefix %>:disabled:opacity-50 <%= prefix %>:focus-visible:outline-2 <%= prefix %>:focus-visible:outline-offset-2 <%= prefix %>:focus-visible:outline-ring',
|
|
10
|
+
{
|
|
11
|
+
variants: {
|
|
12
|
+
variant: {
|
|
13
|
+
default: '<%= prefix %>:bg-primary <%= prefix %>:text-primary-foreground <%= prefix %>:hover:opacity-90',
|
|
14
|
+
secondary: '<%= prefix %>:bg-secondary <%= prefix %>:text-secondary-foreground <%= prefix %>:hover:opacity-80',
|
|
15
|
+
outline: '<%= prefix %>:border <%= prefix %>:border-border <%= prefix %>:bg-background <%= prefix %>:hover:bg-accent <%= prefix %>:hover:text-accent-foreground',
|
|
16
|
+
destructive: '<%= prefix %>:bg-destructive <%= prefix %>:text-white <%= prefix %>:hover:opacity-90',
|
|
17
|
+
ghost: '<%= prefix %>:hover:bg-accent <%= prefix %>:hover:text-accent-foreground',
|
|
18
|
+
},
|
|
19
|
+
size: {
|
|
20
|
+
default: '<%= prefix %>:h-9 <%= prefix %>:px-4 <%= prefix %>:py-2',
|
|
21
|
+
sm: '<%= prefix %>:h-8 <%= prefix %>:px-3',
|
|
22
|
+
lg: '<%= prefix %>:h-10 <%= prefix %>:px-6',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: { variant: 'default', size: 'default' },
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export interface ButtonProps
|
|
30
|
+
extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
|
|
31
|
+
|
|
32
|
+
export function Button({ className, variant, size, type = 'button', ...props }: ButtonProps) {
|
|
33
|
+
return (
|
|
34
|
+
<button type={type} className={cn(buttonVariants({ variant, size }), className)} {...props} />
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<%_ const prefix = it.app.tailwindPrefix _%>
|
|
2
|
+
{
|
|
3
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
4
|
+
"style": "new-york",
|
|
5
|
+
"rsc": false,
|
|
6
|
+
"tsx": true,
|
|
7
|
+
"tailwind": {
|
|
8
|
+
"config": "",
|
|
9
|
+
"css": "src/styles.css",
|
|
10
|
+
"baseColor": "neutral",
|
|
11
|
+
"cssVariables": false,
|
|
12
|
+
"prefix": "<%= prefix %>"
|
|
13
|
+
},
|
|
14
|
+
"aliases": {
|
|
15
|
+
"components": "@/components",
|
|
16
|
+
"ui": "@/components/ui",
|
|
17
|
+
"lib": "@/lib",
|
|
18
|
+
"utils": "@/lib/utils",
|
|
19
|
+
"hooks": "@/hooks"
|
|
20
|
+
},
|
|
21
|
+
"iconLibrary": "lucide"
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<%_ const prefix = it.app.tailwindPrefix _%>
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { cn } from './utils';
|
|
4
|
+
|
|
5
|
+
const prefix = '<%= prefix %>';
|
|
6
|
+
|
|
7
|
+
describe('cn', () => {
|
|
8
|
+
it("keeps the last of two conflicting classes with this remote's prefix", () => {
|
|
9
|
+
expect(cn(`${prefix}:p-2`, `${prefix}:p-4`)).toBe(`${prefix}:p-4`);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('skips false values and keeps classes that do not conflict', () => {
|
|
13
|
+
expect(cn(`${prefix}:flex`, false, undefined, `${prefix}:gap-2`)).toBe(
|
|
14
|
+
`${prefix}:flex ${prefix}:gap-2`,
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<%_ const prefix = it.app.tailwindPrefix _%>
|
|
2
|
+
import { clsx, type ClassValue } from 'clsx';
|
|
3
|
+
import { extendTailwindMerge } from 'tailwind-merge';
|
|
4
|
+
|
|
5
|
+
// Knows this remote's prefix, so cn('<%= prefix %>:p-2', '<%= prefix %>:p-4') keeps only the last one.
|
|
6
|
+
const merge = extendTailwindMerge({ prefix: '<%= prefix %>' });
|
|
7
|
+
|
|
8
|
+
/** Joins class names and drops the ones a later class overrides. */
|
|
9
|
+
export function cn(...inputs: ClassValue[]): string {
|
|
10
|
+
return merge(clsx(inputs));
|
|
11
|
+
}
|
|
@@ -4,9 +4,20 @@ export const endpoints = defineEndpoints({
|
|
|
4
4
|
health: { service: 'api', path: '/health' },
|
|
5
5
|
});
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
const defaults: Record<string, string> = { api: '/api' };
|
|
8
|
+
|
|
9
|
+
// A service's base is `<service>Url` in the runtime config (apiUrl for api) when the shell loaded
|
|
10
|
+
// one, so a build runs in every environment; the default above is for tests and standalone runs.
|
|
11
|
+
function configured(service: string): string | undefined {
|
|
12
|
+
const config: unknown = Reflect.get(globalThis, '__MFE_RUNTIME_CONFIG__');
|
|
13
|
+
const value: unknown =
|
|
14
|
+
typeof config === 'object' && config !== null
|
|
15
|
+
? Reflect.get(config, `${service}Url`)
|
|
16
|
+
: undefined;
|
|
17
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
18
|
+
}
|
|
8
19
|
|
|
9
20
|
export const apiUrl = createUrlFactory(endpoints, (service) => {
|
|
10
|
-
const base =
|
|
21
|
+
const base = configured(service) ?? defaults[service];
|
|
11
22
|
return base === undefined ? undefined : new URL(base, location.origin).toString();
|
|
12
23
|
});
|
|
@@ -164,6 +164,12 @@
|
|
|
164
164
|
"target": "root",
|
|
165
165
|
"dev": true
|
|
166
166
|
},
|
|
167
|
+
{
|
|
168
|
+
"name": "mfeflow",
|
|
169
|
+
"range": "latest",
|
|
170
|
+
"target": "root",
|
|
171
|
+
"dev": true
|
|
172
|
+
},
|
|
167
173
|
{
|
|
168
174
|
"name": "@mfeflow/runtime",
|
|
169
175
|
"range": "latest",
|
|
@@ -271,6 +277,26 @@
|
|
|
271
277
|
"command": "turbo run build",
|
|
272
278
|
"target": "root"
|
|
273
279
|
},
|
|
280
|
+
{
|
|
281
|
+
"name": "doctor",
|
|
282
|
+
"command": "mfeflow doctor",
|
|
283
|
+
"target": "root"
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
"name": "validate",
|
|
287
|
+
"command": "mfeflow validate",
|
|
288
|
+
"target": "root"
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
"name": "check-compat",
|
|
292
|
+
"command": "mfeflow check-compat",
|
|
293
|
+
"target": "root"
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
"name": "budget",
|
|
297
|
+
"command": "mfeflow budget",
|
|
298
|
+
"target": "root"
|
|
299
|
+
},
|
|
274
300
|
{
|
|
275
301
|
"name": "dev",
|
|
276
302
|
"command": "rsbuild dev",
|
|
@@ -4,14 +4,14 @@ Guidance for coding agents working in this repo.
|
|
|
4
4
|
|
|
5
5
|
- Package manager: pnpm (via corepack). Never npm or yarn. Versions live in the `catalog` of
|
|
6
6
|
`pnpm-workspace.yaml`; add dependencies there and reference them as `catalog:`.
|
|
7
|
-
- `pnpm dev` runs the shell and every remote. `pnpm build` builds all of them. `mfeflow dev` does the same with
|
|
7
|
+
- `pnpm dev` runs the shell and every remote. `pnpm build` builds all of them. `pnpm mfeflow dev` does the same with
|
|
8
8
|
per-app logs and picks up new remotes without a restart.
|
|
9
|
-
- Before finishing: `
|
|
9
|
+
- Before finishing: `pnpm doctor` (and `pnpm validate`, `pnpm check-compat`, `pnpm budget` after a build) must exit 0.
|
|
10
10
|
- TypeScript 7, strict. Relative imports have no file extension (`./plan`, not `./plan.js`).
|
|
11
11
|
- Each remote is deployed on its own. A remote must not import from another remote or from
|
|
12
12
|
the shell; talk through the registry, events or URLs.
|
|
13
13
|
- A remote's public surface is `src/exposed/`. Everything else is private to it.
|
|
14
|
-
- Add apps and features with the `mfeflow` CLI (`mfeflow add remote <name>`, `mfeflow features`).
|
|
14
|
+
- Add apps and features with the `mfeflow` CLI, installed as a root dev dependency (`pnpm mfeflow add remote <name>`, `pnpm mfeflow features`).
|
|
15
15
|
`mfeflow.json` is the CLI's record of the project; do not edit the file hashes by hand.
|
|
16
16
|
- Shared singletons (react, react-dom, `@mfeflow/runtime`) are provided by the shell. Do not
|
|
17
17
|
bundle your own copy.
|
|
@@ -7,7 +7,7 @@ one shell and independently deployable remotes, loaded at runtime through Module
|
|
|
7
7
|
apps/shell the host: routes, layout, registry loading
|
|
8
8
|
apps/<remote> one micro-frontend each, exposing ./App
|
|
9
9
|
registry/envs which build of each remote every environment serves
|
|
10
|
-
mfeflow.json what the CLI generated; `mfeflow add` reads and updates it
|
|
10
|
+
mfeflow.json what the CLI generated; `pnpm mfeflow add` reads and updates it
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
## Run it
|
|
@@ -20,28 +20,28 @@ pnpm dev
|
|
|
20
20
|
`pnpm dev` starts the shell on http://localhost:4300 and every remote on its own port
|
|
21
21
|
(see `registry/envs/dev`). The shell reads that dev registry and renders the remotes.
|
|
22
22
|
|
|
23
|
-
`mfeflow dev` does the same with a prefixed, coloured log per app, and it follows the project:
|
|
24
|
-
add a remote with `mfeflow add remote checkout` and it starts, and the shell lists it, without a restart.
|
|
25
|
-
Use a remote you already run elsewhere with `mfeflow dev --remote checkout=http://localhost:3001`,
|
|
23
|
+
`pnpm mfeflow dev` does the same with a prefixed, coloured log per app, and it follows the project:
|
|
24
|
+
add a remote with `pnpm mfeflow add remote checkout` and it starts, and the shell lists it, without a restart.
|
|
25
|
+
Use a remote you already run elsewhere with `pnpm mfeflow dev --remote checkout=http://localhost:3001`,
|
|
26
26
|
or run a few apps with `--only shell,catalog`. Ctrl+C stops everything.
|
|
27
27
|
|
|
28
28
|
## Change it
|
|
29
29
|
|
|
30
30
|
```sh
|
|
31
|
-
mfeflow add remote checkout # a new remote, its registry files and its shell route
|
|
32
|
-
mfeflow add <feature> # see `mfeflow features`
|
|
31
|
+
pnpm mfeflow add remote checkout # a new remote, its registry files and its shell route
|
|
32
|
+
pnpm mfeflow add <feature> # see `pnpm mfeflow features`
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
## Check it
|
|
36
36
|
|
|
37
37
|
```sh
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
pnpm doctor # singletons, compatibility, config, drift; `pnpm doctor --fix` repairs the safe ones
|
|
39
|
+
pnpm validate # mfeflow.json, every mfe.json and every registry entry
|
|
40
|
+
pnpm check-compat # each remote's shared ranges against its shell
|
|
41
|
+
pnpm budget # after `pnpm build`: first-load JS per shell and remote
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
All of them take `--json` and exit non-zero on errors, so they can run in CI. `
|
|
44
|
+
Each is a root script for the `mfeflow` CLI in your devDependencies (`pnpm mfeflow <command>` runs it directly). All of them take `--json` and exit non-zero on errors, so they can run in CI. `pnpm budget` checks each remote's entry
|
|
45
45
|
against the 10 KB a remote may add; set `budget.baselineKb` in `mfeflow.json` (your pages built as one app) to check
|
|
46
46
|
the shell against baseline + 32 KB too.
|
|
47
47
|
|