@mpfrontend/nx-plugin 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +87 -2
- package/dist/feature-generator.d.ts +7 -0
- package/dist/feature-generator.js +9 -0
- package/dist/feature-generator.js.map +1 -0
- package/dist/generator.d.ts +6 -0
- package/dist/generator.js +14 -0
- package/dist/generator.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +46 -0
- package/dist/index.js.map +1 -0
- package/dist/nx-tree.d.ts +5 -0
- package/dist/nx-tree.js +12 -0
- package/dist/nx-tree.js.map +1 -0
- package/dist/package-generator.d.ts +8 -0
- package/dist/package-generator.js +9 -0
- package/dist/package-generator.js.map +1 -0
- package/dist/route-generator.d.ts +8 -0
- package/dist/route-generator.js +9 -0
- package/dist/route-generator.js.map +1 -0
- package/dist/scaffold.d.ts +96 -0
- package/dist/scaffold.js +227 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/templates.d.ts +21 -0
- package/dist/templates.js +83 -0
- package/dist/templates.js.map +1 -0
- package/dist/workspace.d.ts +41 -0
- package/dist/workspace.js +213 -0
- package/dist/workspace.js.map +1 -0
- package/generators.json +24 -0
- package/package.json +37 -3
- package/schema.json +18 -0
- package/schemas/feature-schema.json +25 -0
- package/schemas/package-schema.json +28 -0
- package/schemas/route-schema.json +31 -0
- package/templates/feature/list/api/server/__resource__.ts.template +24 -0
- package/templates/feature/list/app/api/__resource__/route.ts.template +24 -0
- package/templates/feature/list/entities/__resource__/index.ts.template +12 -0
- package/templates/feature/list/entities/__resource__/model/__resource__-list.test.ts.template +23 -0
- package/templates/feature/list/entities/__resource__/model/__resource__-list.ts.template +24 -0
- package/templates/feature/list/entities/__resource__/model/__resource__.test.ts.template +17 -0
- package/templates/feature/list/entities/__resource__/model/__resource__.ts.template +19 -0
- package/templates/feature/list/features/__feature__/api/__feature__-read.ts.template +12 -0
- package/templates/feature/list/features/__feature__/api/__feature__.ts.template +28 -0
- package/templates/feature/list/features/__feature__/hooks/use-__feature__-list.ts.template +22 -0
- package/templates/feature/list/features/__feature__/index.ts.template +3 -0
- package/templates/feature/list/features/__feature__/model/__feature__-fields.ts.template +4 -0
- package/templates/feature/list/features/__feature__/model/overrides/index.ts.template +3 -0
- package/templates/feature/list/features/__feature__/ui/__feature__-detail-screen.tsx.template +16 -0
- package/templates/feature/list/features/__feature__/ui/__feature__-detail.test.tsx.template +28 -0
- package/templates/feature/list/features/__feature__/ui/__feature__-detail.tsx.template +44 -0
- package/templates/feature/list/features/__feature__/ui/__feature__-list.tsx.template +75 -0
- package/templates/feature/list/features/__feature__/ui/__feature__-screen.tsx.template +31 -0
- package/templates/feature/list/features/__feature__/utils/__feature__-href.test.ts.template +7 -0
- package/templates/feature/list/features/__feature__/utils/__feature__-href.ts.template +4 -0
- package/templates/feature/screen/features/__feature__/api/__feature__.ts.template +13 -0
- package/templates/feature/screen/features/__feature__/hooks/use-__feature__.ts.template +27 -0
- package/templates/feature/screen/features/__feature__/index.ts.template +3 -0
- package/templates/feature/screen/features/__feature__/model/__feature__.test.ts.template +12 -0
- package/templates/feature/screen/features/__feature__/model/__feature__.ts.template +12 -0
- package/templates/feature/screen/features/__feature__/ui/__feature__-panel.tsx.template +22 -0
- package/templates/feature/screen/features/__feature__/ui/__feature__-screen.tsx.template +15 -0
- package/templates/feature/screen/features/__feature__/ui/__feature__-view.test.tsx.template +25 -0
- package/templates/feature/screen/features/__feature__/ui/__feature__-view.tsx.template +38 -0
- package/templates/feature/screen/features/__feature__/utils/__feature__.test.ts.template +9 -0
- package/templates/feature/screen/features/__feature__/utils/__feature__.ts.template +6 -0
- package/templates/next/README.md.template +10 -0
- package/templates/next/docs/api-contracts.md.template +22 -0
- package/templates/next/docs/overview.md.template +67 -0
- package/templates/next/eslint.config.mjs.template +4 -0
- package/templates/next/ftg.config.json.template +11 -0
- package/templates/next/next-env.d.ts.template +2 -0
- package/templates/next/next.config.mjs.template +9 -0
- package/templates/next/package.json.template +27 -0
- package/templates/next/postcss.config.mjs.template +2 -0
- package/templates/next/prepare-runtime.mjs.template +11 -0
- package/templates/next/project.json.template +113 -0
- package/templates/next/src/api/server/transfer.test.ts.template +153 -0
- package/templates/next/src/api/server/transfer.ts.template +25 -0
- package/templates/next/src/app/api/access/route.ts.template +6 -0
- package/templates/next/src/app/api/docs/assets/[file]/route.ts.template +23 -0
- package/templates/next/src/app/api/docs/route.ts.template +8 -0
- package/templates/next/src/app/api/openapi/route.ts.template +12 -0
- package/templates/next/src/app/api/transfer/[...path]/route.ts.template +22 -0
- package/templates/next/src/app/components/page.tsx.template +32 -0
- package/templates/next/src/app/globals.css.template +32 -0
- package/templates/next/src/app/layout.tsx.template +53 -0
- package/templates/next/src/app/page.tsx.template +6 -0
- package/templates/next/src/app/providers.tsx.template +31 -0
- package/templates/next/src/config/access.ts.template +21 -0
- package/templates/next/src/config/app.ts.template +33 -0
- package/templates/next/src/config/server.ts.template +13 -0
- package/templates/next/src/features/preferences/api/preference-cookie.ts.template +14 -0
- package/templates/next/src/features/preferences/hooks/use-preferences.ts.template +30 -0
- package/templates/next/src/features/preferences/index.ts.template +3 -0
- package/templates/next/src/features/preferences/model/messages/en.ts.template +10 -0
- package/templates/next/src/features/preferences/model/messages.ts.template +13 -0
- package/templates/next/src/features/preferences/model/preferences.test.ts.template +32 -0
- package/templates/next/src/features/preferences/model/preferences.ts.template +22 -0
- package/templates/next/src/features/preferences/ui/preference-controls.tsx.template +50 -0
- package/templates/next/src/i18n/index.ts.template +13 -0
- package/templates/next/src/i18n/messages/en.ts.template +18 -0
- package/templates/next/src/i18n/server.ts.template +8 -0
- package/templates/next/src/theme/config.test.ts.template +9 -0
- package/templates/next/src/theme/config.ts.template +7 -0
- package/templates/next/src/theme/theme.css.template +8 -0
- package/templates/next/tsconfig.json.template +11 -0
- package/templates/package/README.md.template +10 -0
- package/templates/package/eslint.config.mjs.template +4 -0
- package/templates/package/package.json.template +15 -0
- package/templates/package/project.json.template +51 -0
- package/templates/package/src/index.ts.template +2 -0
- package/templates/package/src/package-name.test.ts.template +7 -0
- package/templates/package/src/package-name.ts.template +2 -0
- package/templates/package/tsconfig.json.template +7 -0
- package/templates/package/tsconfig.lib.json.template +9 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createRelay, type RelayOptions } from '@mpfrontend/presentation-server/relay';
|
|
2
|
+
|
|
3
|
+
/** The context that Next.js passes to the catch-all transfer route. */
|
|
4
|
+
export type TransferContext = { params: Promise<{ path: string[] }> };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The transfer relay: it passes a streamed BFF route between the browser and the BFF, as streams in both
|
|
8
|
+
* directions, and forwards only what the BFF checks and the content headers. It reads no configuration
|
|
9
|
+
* itself, so that a test can run it; the route passes the server configuration. Without a session cookie
|
|
10
|
+
* name the relay is not configured and answers 503.
|
|
11
|
+
*/
|
|
12
|
+
export function transferHandler(options: () => RelayOptions) {
|
|
13
|
+
return async (request: Request, context: TransferContext): Promise<Response> => {
|
|
14
|
+
let relay;
|
|
15
|
+
try {
|
|
16
|
+
relay = createRelay(options());
|
|
17
|
+
} catch {
|
|
18
|
+
return new Response(JSON.stringify({ title: 'TRANSFER_NOT_CONFIGURED' }), {
|
|
19
|
+
status: 503,
|
|
20
|
+
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return relay(request, (await context.params).path);
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
const files = new Set([
|
|
4
|
+
'swagger-ui.css',
|
|
5
|
+
'swagger-ui-bundle.js',
|
|
6
|
+
'swagger-ui-standalone-preset.js',
|
|
7
|
+
]);
|
|
8
|
+
const directory = 'apps/__APP_NAME__/runtime-assets/swagger';
|
|
9
|
+
export async function GET(_request: Request, { params }: { params: Promise<{ file: string }> }) {
|
|
10
|
+
if (process.env.NODE_ENV === 'production' && process.env.ENABLE_API_DOCS !== 'true')
|
|
11
|
+
return new Response(null, { status: 404 });
|
|
12
|
+
const { file } = await params;
|
|
13
|
+
if (!files.has(file)) return new Response(null, { status: 404 });
|
|
14
|
+
const content = await readFile(resolve(process.cwd(), directory, file));
|
|
15
|
+
return new Response(content, {
|
|
16
|
+
headers: {
|
|
17
|
+
'Content-Type': file.endsWith('.css')
|
|
18
|
+
? 'text/css; charset=utf-8'
|
|
19
|
+
: 'application/javascript; charset=utf-8',
|
|
20
|
+
'Cache-Control': 'no-store',
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export async function GET() {
|
|
2
|
+
if (process.env.NODE_ENV === 'production' && process.env.ENABLE_API_DOCS !== 'true')
|
|
3
|
+
return new Response(null, { status: 404 });
|
|
4
|
+
return new Response(
|
|
5
|
+
'<!doctype html><html lang="en"><head><meta charset="utf-8"><title>API documentation</title><link rel="stylesheet" href="/api/docs/assets/swagger-ui.css"></head><body><div id="swagger-ui"></div><script src="/api/docs/assets/swagger-ui-bundle.js"></script><script src="/api/docs/assets/swagger-ui-standalone-preset.js"></script><script>window.onload=function(){SwaggerUIBundle({url:"/api/openapi",dom_id:"#swagger-ui",supportedSubmitMethods:[],presets:[SwaggerUIBundle.presets.apis,SwaggerUIStandalonePreset],layout:"StandaloneLayout"})}</script></body></html>',
|
|
6
|
+
{ headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' } },
|
|
7
|
+
);
|
|
8
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
const app = '__APP_NAME__';
|
|
4
|
+
export async function GET() {
|
|
5
|
+
if (process.env.NODE_ENV === 'production' && process.env.ENABLE_API_DOCS !== 'true')
|
|
6
|
+
return new Response(null, { status: 404 });
|
|
7
|
+
const path = resolve(process.cwd(), 'contracts/openapi/presentation', app, 'openapi.json');
|
|
8
|
+
const bytes = await readFile(path, 'utf8');
|
|
9
|
+
return new Response(bytes, {
|
|
10
|
+
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { appId, bffOrigin, bffSessionCookie } from '../../../../config/server';
|
|
2
|
+
import { transferHandler, type TransferContext } from '../../../../api/server/transfer';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Relays the BFF's streamed routes under `/<app>/transfer/`: uploads and downloads pass as streams. The
|
|
6
|
+
* BFF's allowlist decides which paths, types and sizes exist; this route adds none.
|
|
7
|
+
*/
|
|
8
|
+
const transfer = transferHandler(() => ({
|
|
9
|
+
bffOrigin,
|
|
10
|
+
basePath: '/' + appId + '/transfer',
|
|
11
|
+
sessionCookie: bffSessionCookie ?? '',
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
export async function GET(request: Request, context: TransferContext) {
|
|
15
|
+
return transfer(request, context);
|
|
16
|
+
}
|
|
17
|
+
export async function POST(request: Request, context: TransferContext) {
|
|
18
|
+
return transfer(request, context);
|
|
19
|
+
}
|
|
20
|
+
export async function PUT(request: Request, context: TransferContext) {
|
|
21
|
+
return transfer(request, context);
|
|
22
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { PageFrame } from '@mpfrontend/app-layout';
|
|
2
|
+
import { Button, Field, Select, Card } from '@mpfrontend/ui';
|
|
3
|
+
import { appMessages } from '../../i18n';
|
|
4
|
+
import { requestLocale } from '../../i18n/server';
|
|
5
|
+
|
|
6
|
+
export default async function Components() {
|
|
7
|
+
const t = appMessages.translator(await requestLocale());
|
|
8
|
+
return (
|
|
9
|
+
<PageFrame title={t('componentsTitle')}>
|
|
10
|
+
<Card>
|
|
11
|
+
<div className="flex flex-wrap items-end gap-4">
|
|
12
|
+
<Button>{t('continue')}</Button>
|
|
13
|
+
<Button tone="secondary">{t('continue')}</Button>
|
|
14
|
+
<Button disabled>{t('continue')}</Button>
|
|
15
|
+
</div>
|
|
16
|
+
<div className="mt-8 flex flex-wrap items-end gap-4 [&>.mp-field]:w-[180px]">
|
|
17
|
+
<Field
|
|
18
|
+
label={t('fieldLabel')}
|
|
19
|
+
placeholder={t('fieldPlaceholder')}
|
|
20
|
+
helper={t('fieldHelper')}
|
|
21
|
+
/>
|
|
22
|
+
<Select label={t('account')} defaultValue="">
|
|
23
|
+
<option value="" disabled>
|
|
24
|
+
{t('selectOption')}
|
|
25
|
+
</option>
|
|
26
|
+
<option value="example">{t('exampleAccount')}</option>
|
|
27
|
+
</Select>
|
|
28
|
+
</div>
|
|
29
|
+
</Card>
|
|
30
|
+
</PageFrame>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/* The global entry: imports and base element rules only. Layout comes from framework components and
|
|
2
|
+
utility classes; token values come from the theme layer, which loads after the neutral fallback. */
|
|
3
|
+
@import 'tailwindcss';
|
|
4
|
+
@import '@mpfrontend/ui/tailwind.css';
|
|
5
|
+
@import '@mpfrontend/app-layout/tailwind.css';
|
|
6
|
+
@import '@mpfrontend/tokens/styles.css';
|
|
7
|
+
@import '@mpfrontend/ui/styles.css';
|
|
8
|
+
@import '@mpfrontend/app-layout/styles.css';
|
|
9
|
+
@import '../theme/theme.css';
|
|
10
|
+
|
|
11
|
+
* {
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
}
|
|
14
|
+
body {
|
|
15
|
+
margin: 0;
|
|
16
|
+
background: var(--mp-surface-canvas);
|
|
17
|
+
color: var(--mp-foreground-primary);
|
|
18
|
+
font-family: var(--mp-font-family);
|
|
19
|
+
}
|
|
20
|
+
a {
|
|
21
|
+
color: inherit;
|
|
22
|
+
text-underline-offset: 3px;
|
|
23
|
+
}
|
|
24
|
+
button,
|
|
25
|
+
input,
|
|
26
|
+
select {
|
|
27
|
+
font: inherit;
|
|
28
|
+
}
|
|
29
|
+
:focus-visible {
|
|
30
|
+
outline: 2px solid var(--mp-focus-ring);
|
|
31
|
+
outline-offset: 2px;
|
|
32
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { cookies } from 'next/headers';
|
|
3
|
+
import { AppFrame, AppHeader, Navigation } from '@mpfrontend/app-layout';
|
|
4
|
+
import { PreferenceControls, readPreferences } from '../features/preferences';
|
|
5
|
+
import { appId, localeRegistry } from '../config/app';
|
|
6
|
+
import { visitorAccess } from '../config/access';
|
|
7
|
+
import { appMessages } from '../i18n';
|
|
8
|
+
import { Providers } from './providers';
|
|
9
|
+
import './globals.css';
|
|
10
|
+
|
|
11
|
+
export async function generateMetadata() {
|
|
12
|
+
const saved = await cookies();
|
|
13
|
+
const t = appMessages.translator(readPreferences((name) => saved.get(name)?.value).locale);
|
|
14
|
+
return { title: appId + ' | MP Frontend', description: t('description') };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default async function RootLayout({ children }: { children: ReactNode }) {
|
|
18
|
+
const saved = await cookies();
|
|
19
|
+
// The first paint takes language, direction and theme from the preference cookie, so nothing flashes.
|
|
20
|
+
const preferences = readPreferences((name) => saved.get(name)?.value);
|
|
21
|
+
const t = appMessages.translator(preferences.locale);
|
|
22
|
+
// The app passes the items it may show; the shell decides nothing about access.
|
|
23
|
+
const items = [
|
|
24
|
+
{ href: '/catalog', label: t('catalog') },
|
|
25
|
+
{ href: '/components', label: t('components') },
|
|
26
|
+
{ href: '/api/docs', label: t('docs') },
|
|
27
|
+
];
|
|
28
|
+
return (
|
|
29
|
+
<html
|
|
30
|
+
lang={preferences.locale}
|
|
31
|
+
dir={localeRegistry.direction(preferences.locale)}
|
|
32
|
+
data-brand={preferences.brand}
|
|
33
|
+
data-mode={preferences.mode}
|
|
34
|
+
>
|
|
35
|
+
<body>
|
|
36
|
+
<Providers snapshot={visitorAccess()}>
|
|
37
|
+
<AppFrame
|
|
38
|
+
skipLinkLabel={t('skipToContent')}
|
|
39
|
+
header={
|
|
40
|
+
<AppHeader
|
|
41
|
+
brand={<a href="/catalog">{appId}</a>}
|
|
42
|
+
navigation={<Navigation label={t('navigation')} items={items} />}
|
|
43
|
+
actions={<PreferenceControls initial={preferences} />}
|
|
44
|
+
/>
|
|
45
|
+
}
|
|
46
|
+
>
|
|
47
|
+
{children}
|
|
48
|
+
</AppFrame>
|
|
49
|
+
</Providers>
|
|
50
|
+
</body>
|
|
51
|
+
</html>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useState, type ReactNode } from 'react';
|
|
3
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
4
|
+
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
|
5
|
+
import { AccessProvider } from '@mpfrontend/access-react';
|
|
6
|
+
import { QueryScope } from '@mpfrontend/data-react';
|
|
7
|
+
|
|
8
|
+
const reloadAccess = async (signal: AbortSignal): Promise<unknown> =>
|
|
9
|
+
(await fetch('/api/access', { signal, cache: 'no-store' })).json();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The client providers of the app: its own query client, the URL state adapter and the access snapshot. Server data
|
|
13
|
+
* is cached under the access scope only, and a write is never retried automatically.
|
|
14
|
+
*/
|
|
15
|
+
export function Providers({ snapshot, children }: { snapshot: unknown; children: ReactNode }) {
|
|
16
|
+
const [client] = useState(
|
|
17
|
+
() =>
|
|
18
|
+
new QueryClient({
|
|
19
|
+
defaultOptions: { queries: { staleTime: 30000 }, mutations: { retry: false } },
|
|
20
|
+
}),
|
|
21
|
+
);
|
|
22
|
+
return (
|
|
23
|
+
<QueryClientProvider client={client}>
|
|
24
|
+
<NuqsAdapter>
|
|
25
|
+
<AccessProvider snapshot={snapshot} reload={reloadAccess}>
|
|
26
|
+
<QueryScope>{children}</QueryScope>
|
|
27
|
+
</AccessProvider>
|
|
28
|
+
</NuqsAdapter>
|
|
29
|
+
</QueryClientProvider>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import type { PageAccess } from '@mpfrontend/access-core';
|
|
3
|
+
|
|
4
|
+
/** How long the provider uses a visitor snapshot before it closes it and asks again. */
|
|
5
|
+
const VISITOR_LIFETIME = 15 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The access snapshot of a visitor who has not signed in: it names the scope of the data the app reads for them and
|
|
9
|
+
* grants no capability and no field. A product that signs people in loads each person's snapshot instead, with
|
|
10
|
+
* `createAccessLoader` of `@mpfrontend/access-react/server` from its own access endpoint through the BFF; its
|
|
11
|
+
* context reference and revision change when the person, the tenant or the grants change.
|
|
12
|
+
*/
|
|
13
|
+
export function visitorAccess(now = Date.now()): PageAccess {
|
|
14
|
+
return {
|
|
15
|
+
contextRef: 'visitor',
|
|
16
|
+
revision: 'visitor-1',
|
|
17
|
+
expiresAt: now + VISITOR_LIFETIME,
|
|
18
|
+
capabilities: {},
|
|
19
|
+
fields: {},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createLocaleRegistry, createPreferenceCookie } from '@mpfrontend/i18n';
|
|
2
|
+
|
|
3
|
+
/** The app id, safe on the client and the server. */
|
|
4
|
+
export const appId = '__APP_NAME__';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The locales of this app; direction comes from here. The default is the first-visit locale. MP Frontend
|
|
8
|
+
* ships English only: add each locale of the product here with its direction, for example
|
|
9
|
+
* `'<code>': { direction: 'rtl' }`, and a `messages/<code>.ts` catalog to every catalog set (the catalog
|
|
10
|
+
* check lists the missing ones). A product locale may also replace English as the default and only locale.
|
|
11
|
+
*/
|
|
12
|
+
export const localeRegistry = createLocaleRegistry({
|
|
13
|
+
locales: { en: { direction: 'ltr' } },
|
|
14
|
+
defaultLocale: 'en',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The preference cookie shared with the identity provider's pages: language and theme only, allowlisted
|
|
19
|
+
* values, never identity or authority. Set `preferenceCookieDomain` to the common parent domain to share it.
|
|
20
|
+
*/
|
|
21
|
+
export const preferenceCookie = createPreferenceCookie({
|
|
22
|
+
name: 'mp_preferences',
|
|
23
|
+
locales: localeRegistry.locales,
|
|
24
|
+
themes: ['light', 'dark', 'system'],
|
|
25
|
+
});
|
|
26
|
+
export const preferenceCookieDomain: string | undefined = undefined;
|
|
27
|
+
/** The app's own brand choice; brands are not part of the shared preference cookie. */
|
|
28
|
+
export const brandCookie = appId + '_brand';
|
|
29
|
+
|
|
30
|
+
/** The visitor's language from the preference cookie's raw value, or the registry default. */
|
|
31
|
+
export function visitorLocale(cookieValue: string | undefined) {
|
|
32
|
+
return localeRegistry.locale(preferenceCookie.parse(cookieValue).lang);
|
|
33
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
export const bffOrigin = process.env.BFF_ORIGIN ?? 'http://127.0.0.1:4310';
|
|
3
|
+
const parsed = new URL(bffOrigin);
|
|
4
|
+
if (
|
|
5
|
+
!['http:', 'https:'].includes(parsed.protocol) ||
|
|
6
|
+
parsed.username ||
|
|
7
|
+
parsed.password ||
|
|
8
|
+
parsed.pathname !== '/'
|
|
9
|
+
)
|
|
10
|
+
throw new Error('INVALID_BFF_ORIGIN');
|
|
11
|
+
export const appId = '__APP_NAME__';
|
|
12
|
+
/** The name of the BFF's session cookie: the one cookie the transfer relay forwards to the BFF. */
|
|
13
|
+
export const bffSessionCookie = process.env.BFF_SESSION_COOKIE;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { brandCookie, preferenceCookie, preferenceCookieDomain } from '../../../config/app';
|
|
2
|
+
import type { Preferences } from '../model/preferences';
|
|
3
|
+
|
|
4
|
+
const attributes = () =>
|
|
5
|
+
'; Path=/; SameSite=Lax; Max-Age=31536000' +
|
|
6
|
+
(location.protocol === 'https:' ? '; Secure' : '') +
|
|
7
|
+
(preferenceCookieDomain ? '; Domain=' + preferenceCookieDomain : '');
|
|
8
|
+
|
|
9
|
+
/** Persists validated preferences for the next first paint; the cookies hold no identity. */
|
|
10
|
+
export function persistPreferences(preferences: Preferences): void {
|
|
11
|
+
const shared = preferenceCookie.value({ lang: preferences.locale, theme: preferences.mode });
|
|
12
|
+
document.cookie = preferenceCookie.name + '=' + shared + attributes();
|
|
13
|
+
document.cookie = brandCookie + '=' + encodeURIComponent(preferences.brand) + attributes();
|
|
14
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { useRouter } from 'next/navigation';
|
|
4
|
+
import { validMode } from '@mpfrontend/tokens';
|
|
5
|
+
import { localeRegistry } from '../../../config/app';
|
|
6
|
+
import { validBrand } from '../../../theme/config';
|
|
7
|
+
import { persistPreferences } from '../api/preference-cookie';
|
|
8
|
+
import type { PreferenceKey, Preferences } from '../model/preferences';
|
|
9
|
+
|
|
10
|
+
/** Applies a changed preference to the document at once, persists it and re-renders the server parts. */
|
|
11
|
+
export function usePreferences(initial: Preferences) {
|
|
12
|
+
const router = useRouter();
|
|
13
|
+
const [preferences, setPreferences] = useState(initial);
|
|
14
|
+
function change(key: PreferenceKey, value: string) {
|
|
15
|
+
const next: Preferences = {
|
|
16
|
+
locale: key === 'locale' ? localeRegistry.locale(value) : preferences.locale,
|
|
17
|
+
brand: key === 'brand' ? validBrand(value) : preferences.brand,
|
|
18
|
+
mode: key === 'mode' ? validMode(value) : preferences.mode,
|
|
19
|
+
};
|
|
20
|
+
setPreferences(next);
|
|
21
|
+
const root = document.documentElement;
|
|
22
|
+
root.lang = next.locale;
|
|
23
|
+
root.dir = localeRegistry.direction(next.locale);
|
|
24
|
+
root.dataset.brand = next.brand;
|
|
25
|
+
root.dataset.mode = next.mode;
|
|
26
|
+
persistPreferences(next);
|
|
27
|
+
if (key === 'locale') router.refresh();
|
|
28
|
+
}
|
|
29
|
+
return { preferences, change };
|
|
30
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { coreMessages, createMessages } from '@mpfrontend/i18n';
|
|
2
|
+
import { localeRegistry } from '../../../config/app';
|
|
3
|
+
import base from './messages/en';
|
|
4
|
+
|
|
5
|
+
/** The messages of this unit. The base catalog defines the keys and their parameters. */
|
|
6
|
+
export const messages = createMessages({
|
|
7
|
+
defaultLocale: 'en',
|
|
8
|
+
base,
|
|
9
|
+
// Each other locale of the registry: import its catalog and list it here.
|
|
10
|
+
translations: {},
|
|
11
|
+
core: coreMessages,
|
|
12
|
+
numberingSystem: localeRegistry.numberingSystem,
|
|
13
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { localeRegistry } from '../../../config/app';
|
|
4
|
+
import { readPreferences } from './preferences';
|
|
5
|
+
|
|
6
|
+
const jar = (values: Record<string, string>) => (name: string) => values[name];
|
|
7
|
+
// The test follows the app's registry, so it holds when the product adds or replaces locales.
|
|
8
|
+
const chosen = localeRegistry.locales[localeRegistry.locales.length - 1]!;
|
|
9
|
+
|
|
10
|
+
test('preferences come from the shared preference cookie and the brand cookie', () => {
|
|
11
|
+
assert.deepEqual(
|
|
12
|
+
readPreferences(
|
|
13
|
+
jar({ mp_preferences: 'lang=' + chosen + '&theme=dark', __APP_NAME___brand: 'neutral' }),
|
|
14
|
+
),
|
|
15
|
+
{
|
|
16
|
+
locale: chosen,
|
|
17
|
+
brand: 'neutral',
|
|
18
|
+
mode: 'dark',
|
|
19
|
+
},
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('missing or invalid values fall back to the defaults and are never echoed', () => {
|
|
24
|
+
const defaults = { locale: localeRegistry.defaultLocale, brand: 'neutral', mode: 'system' };
|
|
25
|
+
assert.deepEqual(readPreferences(jar({})), defaults);
|
|
26
|
+
assert.deepEqual(
|
|
27
|
+
readPreferences(
|
|
28
|
+
jar({ mp_preferences: 'lang=<x>&theme=bright&role=admin', __APP_NAME___brand: 'other' }),
|
|
29
|
+
),
|
|
30
|
+
defaults,
|
|
31
|
+
);
|
|
32
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { validMode, type Mode } from '@mpfrontend/tokens';
|
|
2
|
+
import { brandCookie, localeRegistry, preferenceCookie } from '../../../config/app';
|
|
3
|
+
import { validBrand, type Brand } from '../../../theme/config';
|
|
4
|
+
|
|
5
|
+
export type AppLocale = (typeof localeRegistry.locales)[number];
|
|
6
|
+
/** The presentation preferences of a visitor. They change presentation only, never authority. */
|
|
7
|
+
export type Preferences = Readonly<{ locale: AppLocale; brand: Brand; mode: Mode }>;
|
|
8
|
+
export type PreferenceKey = keyof Preferences;
|
|
9
|
+
export const modes = ['light', 'dark', 'system'] as const;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reads the shared preference cookie (language and theme) and the app's brand cookie by name. Every value is
|
|
13
|
+
* validated; an unknown or missing value is the default, and an invalid cookie is ignored.
|
|
14
|
+
*/
|
|
15
|
+
export function readPreferences(cookie: (name: string) => string | undefined): Preferences {
|
|
16
|
+
const shared = preferenceCookie.parse(cookie(preferenceCookie.name));
|
|
17
|
+
return {
|
|
18
|
+
locale: localeRegistry.locale(shared.lang),
|
|
19
|
+
brand: validBrand(cookie(brandCookie)),
|
|
20
|
+
mode: validMode(shared.theme),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { Select } from '@mpfrontend/ui';
|
|
3
|
+
import { localeRegistry } from '../../../config/app';
|
|
4
|
+
import { brands } from '../../../theme/config';
|
|
5
|
+
import { usePreferences } from '../hooks/use-preferences';
|
|
6
|
+
import { messages } from '../model/messages';
|
|
7
|
+
import { modes, type Preferences } from '../model/preferences';
|
|
8
|
+
|
|
9
|
+
/** The language, brand and appearance controls. The view renders choices and emits changes. */
|
|
10
|
+
export function PreferenceControls({ initial }: { initial: Preferences }) {
|
|
11
|
+
const { preferences, change } = usePreferences(initial);
|
|
12
|
+
const t = messages.translator(preferences.locale);
|
|
13
|
+
return (
|
|
14
|
+
<div className="flex flex-wrap items-end gap-4 [&>.mp-field]:w-[180px]">
|
|
15
|
+
<Select
|
|
16
|
+
label={t('language')}
|
|
17
|
+
value={preferences.locale}
|
|
18
|
+
onChange={(event) => change('locale', event.target.value)}
|
|
19
|
+
>
|
|
20
|
+
{localeRegistry.locales.map((code) => (
|
|
21
|
+
<option key={code} value={code} lang={code}>
|
|
22
|
+
{new Intl.DisplayNames([code], { type: 'language' }).of(code)}
|
|
23
|
+
</option>
|
|
24
|
+
))}
|
|
25
|
+
</Select>
|
|
26
|
+
<Select
|
|
27
|
+
label={t('brand')}
|
|
28
|
+
value={preferences.brand}
|
|
29
|
+
onChange={(event) => change('brand', event.target.value)}
|
|
30
|
+
>
|
|
31
|
+
{brands.map((value) => (
|
|
32
|
+
<option key={value} value={value}>
|
|
33
|
+
{value}
|
|
34
|
+
</option>
|
|
35
|
+
))}
|
|
36
|
+
</Select>
|
|
37
|
+
<Select
|
|
38
|
+
label={t('mode')}
|
|
39
|
+
value={preferences.mode}
|
|
40
|
+
onChange={(event) => change('mode', event.target.value)}
|
|
41
|
+
>
|
|
42
|
+
{modes.map((value) => (
|
|
43
|
+
<option key={value} value={value}>
|
|
44
|
+
{t(value)}
|
|
45
|
+
</option>
|
|
46
|
+
))}
|
|
47
|
+
</Select>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { coreMessages, createMessages } from '@mpfrontend/i18n';
|
|
2
|
+
import { localeRegistry } from '../config/app';
|
|
3
|
+
import base from './messages/en';
|
|
4
|
+
|
|
5
|
+
/** The app-wide messages: the layout and app-level screens. Each feature has its own catalog set. */
|
|
6
|
+
export const appMessages = createMessages({
|
|
7
|
+
defaultLocale: 'en',
|
|
8
|
+
base,
|
|
9
|
+
// Each other locale of the registry: import its catalog and list it here.
|
|
10
|
+
translations: {},
|
|
11
|
+
core: coreMessages,
|
|
12
|
+
numberingSystem: localeRegistry.numberingSystem,
|
|
13
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineMessages } from '@mpfrontend/i18n';
|
|
2
|
+
|
|
3
|
+
export default defineMessages({
|
|
4
|
+
description: 'Independent MP Frontend consumer',
|
|
5
|
+
skipToContent: 'Skip to content',
|
|
6
|
+
navigation: 'Main navigation',
|
|
7
|
+
catalog: 'Catalog',
|
|
8
|
+
components: 'Components',
|
|
9
|
+
docs: 'API documentation',
|
|
10
|
+
componentsTitle: 'MP Frontend component foundation',
|
|
11
|
+
continue: 'Continue',
|
|
12
|
+
fieldLabel: 'Field label',
|
|
13
|
+
fieldPlaceholder: 'Enter value',
|
|
14
|
+
fieldHelper: 'Helper text',
|
|
15
|
+
account: 'Account',
|
|
16
|
+
selectOption: 'Select an option',
|
|
17
|
+
exampleAccount: 'Example account',
|
|
18
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { cookies } from 'next/headers';
|
|
3
|
+
import { preferenceCookie, visitorLocale } from '../config/app';
|
|
4
|
+
|
|
5
|
+
/** The locale of the current request: the visitor's validated preference, or the registry default. */
|
|
6
|
+
export async function requestLocale() {
|
|
7
|
+
return visitorLocale((await cookies()).get(preferenceCookie.name)?.value);
|
|
8
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { brands, validBrand } from './config';
|
|
4
|
+
|
|
5
|
+
test('the brand registry accepts its own brands and falls back to its default', () => {
|
|
6
|
+
for (const brand of brands) assert.equal(validBrand(brand), brand);
|
|
7
|
+
assert.equal(validBrand('unregistered'), 'neutral');
|
|
8
|
+
assert.equal(validBrand(undefined), 'neutral');
|
|
9
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { validBrand as validateBrand } from '@mpfrontend/tokens';
|
|
2
|
+
// The app owns its approved theme registry. Core never inserts a product DLS.
|
|
3
|
+
export const brands = ['neutral'] as const;
|
|
4
|
+
export type Brand = (typeof brands)[number];
|
|
5
|
+
export function validBrand(value: unknown): Brand {
|
|
6
|
+
return validateBrand(value, brands, 'neutral');
|
|
7
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/* The app theme: the one file of this app that sets design-token values. globals.css loads it after the
|
|
2
|
+
neutral fallback of @mpfrontend/tokens, so a value here wins. Scope values to a brand of the registry in
|
|
3
|
+
config.ts. With an attached design source, import its generated tokens instead of writing values:
|
|
4
|
+
@import '../../../../themes/<id>/generated/tokens.gen.css'; */
|
|
5
|
+
:root[data-brand='neutral'] {
|
|
6
|
+
--mp-shell-content-max: 75rem;
|
|
7
|
+
--mp-shell-gutter: 2rem;
|
|
8
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# @__SCOPE__/__name__
|
|
2
|
+
|
|
3
|
+
A shared package of this workspace for code that two or more apps need. Runtime: `__runtime__`. Its public
|
|
4
|
+
API is `src/index.ts`, compiled to `dist/`; apps and other packages import only `@__SCOPE__/__name__`.
|
|
5
|
+
|
|
6
|
+
- Tags: `type:package`, `scope:shared` and `runtime:__runtime__`. Lint refuses an import of an app from
|
|
7
|
+
this package and a dependency that crosses a runtime tag.
|
|
8
|
+
- An app uses it by adding `"@__SCOPE__/__name__": "workspace:*"` to the dependencies of its
|
|
9
|
+
`package.json` and running `pnpm install`; the app build then builds this package first.
|
|
10
|
+
- Targets: `build`, `typecheck`, `format`, `format:check`, `lint` and `test`.
|