handoff-app 2.0.0-11 → 2.0.0-13
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/{Changelog.md → CHANGELOG.md} +84 -0
- package/{License.md → LICENSE.md} +1 -1
- package/README.md +760 -801
- package/RECIPES.md +975 -0
- package/components.json +20 -20
- package/config/docs/foundations/colors/guidelines.md +0 -1
- package/config/docs/foundations/colors.md +0 -1
- package/config/docs/foundations/effects/guidelines.md +0 -1
- package/config/docs/foundations/effects/resources.md +0 -1
- package/config/docs/foundations/effects.md +0 -1
- package/config/docs/foundations/fonts.md +0 -1
- package/config/docs/foundations/grid.md +0 -1
- package/config/docs/foundations/icon.md +0 -1
- package/config/docs/foundations/icons/guidelines.md +0 -1
- package/config/docs/foundations/icons.md +0 -1
- package/config/docs/foundations/images/guidelines.md +0 -1
- package/config/docs/foundations/images/resources.md +0 -1
- package/config/docs/foundations/images.md +0 -1
- package/config/docs/foundations/logo/resources.md +0 -1
- package/config/docs/foundations/logo.md +0 -1
- package/config/docs/foundations/typography/guidelines.md +0 -1
- package/config/docs/foundations/typography/resources.md +0 -1
- package/config/docs/foundations/typography.md +0 -1
- package/config/docs/foundations.md +0 -1
- package/config/docs/guidelines.md +0 -1
- package/config/docs/index.md +0 -1
- package/config/docs/system/tokens/foundations/colors.md +0 -4
- package/config/docs/system/tokens/foundations/effects.md +0 -4
- package/config/docs/system/tokens/foundations/typography.md +0 -2
- package/config/docs/system.md +0 -2
- package/config/templates/page.md +2 -1
- package/config/templates/page.mdx +2 -1
- package/dist/ai/define.d.ts +1 -1
- package/dist/ai/define.js +1 -1
- package/dist/ai/types.d.ts +1 -1
- package/dist/ai/types.js +1 -1
- package/dist/app-builder/build.js +16 -16
- package/dist/app-builder/client-config.js +1 -1
- package/dist/config/loader.js +1 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/nav/index.d.ts +2 -2
- package/dist/nav/registry.d.ts +3 -3
- package/dist/nav/registry.js +1 -1
- package/dist/nav/workspace.d.ts +3 -3
- package/dist/nav/workspace.js +1 -1
- package/dist/providers.d.ts +10 -0
- package/dist/{define.js → providers.js} +4 -4
- package/dist/registry/asset-storage/define.d.ts +5 -5
- package/dist/registry/asset-storage/define.js +4 -4
- package/dist/registry/asset-storage/resolve.d.ts +8 -11
- package/dist/registry/asset-storage/resolve.js +12 -13
- package/dist/registry/asset-storage/types.d.ts +12 -12
- package/dist/registry/asset-storage/types.js +2 -2
- package/dist/registry/db/schema.d.ts +2 -2
- package/dist/registry/db/schema.js +2 -2
- package/dist/registry/email/define.d.ts +1 -1
- package/dist/registry/email/define.js +1 -1
- package/dist/registry/email/resolve.d.ts +7 -8
- package/dist/registry/email/resolve.js +21 -23
- package/dist/registry/email/types.d.ts +1 -1
- package/dist/registry/email/types.js +1 -1
- package/dist/store/registry.d.ts +3 -3
- package/dist/store/registry.js +4 -4
- package/dist/types/config.d.ts +4 -13
- package/docs/cli.md +275 -54
- package/package.json +8 -9
- package/src/ai/define.ts +1 -1
- package/src/ai/types.ts +1 -1
- package/src/app/components/Auth/AccountLayout.tsx +1 -1
- package/src/app/components/Auth/AuthShell.tsx +10 -4
- package/src/app/components/Foundations/ColorGrid.tsx +13 -14
- package/src/app/components/Layout/Main.tsx +2 -2
- package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
- package/src/app/components/Navigation/MobileNav.tsx +0 -2
- package/src/app/components/util/index.ts +0 -11
- package/src/app/lib/asset-storage.ts +25 -25
- package/src/app/lib/auth/email.ts +4 -5
- package/src/app/lib/docs-api/page-rendering.ts +1 -1
- package/src/app/lib/docs-api/registry.ts +2 -3
- package/src/app/lib/docs-api/runtime-config.ts +8 -8
- package/src/app/lib/mcp/components.ts +33 -29
- package/src/app/lib/mcp/pages.ts +26 -19
- package/src/app/lib/mcp/server.ts +33 -9
- package/src/app/lib/mcp/tokens.ts +22 -16
- package/src/app/lib/mcp/tool.ts +19 -0
- package/src/app/lib/registry-api/asset-transfer.ts +25 -25
- package/src/app/lib/server-module.ts +1 -1
- package/src/app/lib/utils.ts +7 -0
- package/src/app/next.config.mjs +2 -2
- package/src/app/pages/[...slug]/index.tsx +2 -1
- package/src/app/pages/account/ai.tsx +1 -1
- package/src/app/pages/account/index.tsx +1 -1
- package/src/app/pages/account/tokens.tsx +1 -1
- package/src/app/pages/account/users.tsx +1 -1
- package/src/app/pages/api/docs/nav.json.ts +1 -1
- package/src/app/pages/api/mcp.ts +6 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
- package/src/app/pages/cli/device.tsx +1 -1
- package/src/app/pages/foundations/colors.tsx +9 -8
- package/src/app/pages/foundations/index.tsx +3 -3
- package/src/app/pages/index.tsx +2 -2
- package/src/app/pages/login.tsx +10 -5
- package/src/app/pages/reset-password.tsx +7 -2
- package/src/app/pages/system/component/[component]/index.tsx +2 -5
- package/src/app/pages/system/index.tsx +11 -11
- package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
- package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
- package/src/app-builder/build.ts +12 -12
- package/src/app-builder/client-config.ts +1 -1
- package/src/config/loader.ts +1 -2
- package/src/index.ts +6 -6
- package/src/nav/index.ts +2 -2
- package/src/nav/registry.ts +3 -3
- package/src/nav/workspace.ts +3 -3
- package/src/{define.ts → providers.ts} +8 -8
- package/src/registry/asset-storage/define.ts +6 -6
- package/src/registry/asset-storage/resolve.ts +16 -20
- package/src/registry/asset-storage/types.ts +12 -12
- package/src/registry/db/schema.ts +3 -3
- package/src/registry/email/define.ts +1 -1
- package/src/registry/email/resolve.ts +23 -23
- package/src/registry/email/types.ts +1 -1
- package/src/store/registry.ts +7 -7
- package/src/types/config.ts +4 -14
- package/.editorconfig +0 -19
- package/.eslintrc.json +0 -4
- package/.prettierignore +0 -0
- package/.prettierrc.json +0 -8
- package/.vscode/settings.json +0 -17
- package/dist/define.d.ts +0 -10
- package/dist/registry/email/adapters/resend.d.ts +0 -7
- package/dist/registry/email/adapters/resend.js +0 -35
- package/src/app/components/old/Header.tsx +0 -89
- package/src/app/pages/assets/fonts.tsx +0 -129
- package/src/app/pages/assets/index.tsx +0 -91
- package/src/app/pages/assets/logos.tsx +0 -75
- package/src/registry/email/adapters/resend.ts +0 -22
- /package/dist/registry/email/{adapters → providers}/smtp.d.ts +0 -0
- /package/dist/registry/email/{adapters → providers}/smtp.js +0 -0
- /package/src/registry/email/{adapters → providers}/smtp.ts +0 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv';
|
|
4
|
+
import { componentTools } from './components';
|
|
5
|
+
import { pageTools } from './pages';
|
|
6
|
+
import { fail } from './result';
|
|
7
|
+
import { tokenTools } from './tokens';
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* The Handoff MCP server: read-only access to the design system's components, tokens and pages.
|
|
@@ -21,10 +24,19 @@ import { registerTokenTools } from './tokens';
|
|
|
21
24
|
/** Version of the MCP surface itself, reported in `initialize`. Bump when a tool contract changes. */
|
|
22
25
|
const MCP_SERVER_VERSION = '2.0.0';
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
const tools = [...componentTools, ...tokenTools, ...pageTools];
|
|
28
|
+
|
|
29
|
+
/** Compiled once per process, not per request. */
|
|
30
|
+
const validator = new AjvJsonSchemaValidator();
|
|
31
|
+
const toolsByName = new Map(
|
|
32
|
+
tools.map((tool) => [tool.definition.name, { ...tool, validate: validator.getValidator(tool.definition.inputSchema) }])
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export const createMcpServer = (): Server => {
|
|
36
|
+
const server = new Server(
|
|
26
37
|
{ name: 'handoff', version: MCP_SERVER_VERSION },
|
|
27
38
|
{
|
|
39
|
+
capabilities: { tools: {} },
|
|
28
40
|
instructions:
|
|
29
41
|
'Design-system knowledge for this project. Before writing UI code, find an existing component ' +
|
|
30
42
|
'with handoff_search_components and read it with handoff_get_component. Take colors, typography ' +
|
|
@@ -33,8 +45,20 @@ export const createMcpServer = (): McpServer => {
|
|
|
33
45
|
}
|
|
34
46
|
);
|
|
35
47
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
48
|
+
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: tools.map((tool) => tool.definition) }));
|
|
49
|
+
|
|
50
|
+
// Unknown tools and invalid arguments are tool errors, so the agent can read them and retry.
|
|
51
|
+
server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
|
|
52
|
+
const tool = toolsByName.get(params.name);
|
|
53
|
+
if (!tool) {
|
|
54
|
+
return fail(`Tool "${params.name}" was not found.`);
|
|
55
|
+
}
|
|
56
|
+
const args = tool.validate(params.arguments ?? {});
|
|
57
|
+
if (!args.valid) {
|
|
58
|
+
return fail(`Invalid arguments for tool "${params.name}": ${args.errorMessage}.`);
|
|
59
|
+
}
|
|
60
|
+
return tool.run(args.data);
|
|
61
|
+
});
|
|
62
|
+
|
|
39
63
|
return server;
|
|
40
64
|
};
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
import { z } from 'zod';
|
|
3
1
|
import type { TokenArtifactResource } from '@handoff/store';
|
|
4
2
|
import type { DocsBackend, TokenSetDetail } from '../docs-api/backend';
|
|
5
3
|
import { fail, ok, read } from './result';
|
|
4
|
+
import { defineTool, type McpTool } from './tool';
|
|
6
5
|
|
|
7
6
|
/** The token tool, and the reshaping that turns stored token sets into variables an agent can emit. */
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
type TokenFormat = 'css' | 'scss' | 'styleDictionary' | 'types';
|
|
9
|
+
|
|
10
|
+
export const tokenTools: McpTool[] = [
|
|
11
|
+
defineTool<{ set?: string; kind?: 'foundation' | 'component'; format?: TokenFormat }>(
|
|
12
12
|
{
|
|
13
|
+
name: 'handoff_get_tokens',
|
|
13
14
|
title: 'Get design tokens',
|
|
14
15
|
description:
|
|
15
16
|
'Design tokens. With no arguments: the list of token sets, plus all foundation tokens (colors, ' +
|
|
@@ -20,15 +21,20 @@ export const registerTokenTools = (server: McpServer): void => {
|
|
|
20
21
|
'stylesheet is available.',
|
|
21
22
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
22
23
|
inputSchema: {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
set: { type: 'string', description: 'Token set id from the list, for example "foundation/colors".' },
|
|
27
|
+
kind: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
enum: ['foundation', 'component'],
|
|
30
|
+
description: 'Without `set`: list only sets of this kind. `component` leaves out the foundation tokens.',
|
|
31
|
+
},
|
|
32
|
+
format: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
enum: ['css', 'scss', 'styleDictionary', 'types'] satisfies TokenFormat[],
|
|
35
|
+
description: 'With `set`: return the generated file in this format.',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
32
38
|
},
|
|
33
39
|
},
|
|
34
40
|
async ({ set, kind, format }) =>
|
|
@@ -57,8 +63,8 @@ export const registerTokenTools = (server: McpServer): void => {
|
|
|
57
63
|
}
|
|
58
64
|
return ok({ id: detail.id, kind: detail.kind, format, path: artifact.path, content: artifact.content });
|
|
59
65
|
})
|
|
60
|
-
)
|
|
61
|
-
|
|
66
|
+
),
|
|
67
|
+
];
|
|
62
68
|
|
|
63
69
|
/**
|
|
64
70
|
* A token set's payload, under a key naming which of the four forms it is, so the agent never has to
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One MCP tool: its listing, with a plain JSON Schema for its input, and its handler.
|
|
5
|
+
*
|
|
6
|
+
* Plain JSON Schema keeps Zod out of the app. The Next build type-checks this code against the
|
|
7
|
+
* consumer's `node_modules`, where a second Zod copy makes the SDK's Zod types incompatible with ours.
|
|
8
|
+
*/
|
|
9
|
+
export interface McpTool {
|
|
10
|
+
definition: Tool;
|
|
11
|
+
/** Runs with arguments already validated against `definition.inputSchema`. */
|
|
12
|
+
run: (args: unknown) => Promise<CallToolResult>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** `Args` must match `definition.inputSchema`; the server validates against the schema before `run`. */
|
|
16
|
+
export const defineTool = <Args>(definition: Tool, run: (args: Args) => Promise<CallToolResult>): McpTool => ({
|
|
17
|
+
definition,
|
|
18
|
+
run: run as McpTool['run'],
|
|
19
|
+
});
|
|
@@ -6,9 +6,9 @@ import type { RegistryDatabase } from '@handoff/registry/db/client';
|
|
|
6
6
|
import { assetBlobs, assetCollections, assets } from '@handoff/registry/db/schema';
|
|
7
7
|
import { isAssetCollection, type AssetCollection } from '@handoff/registry/assets/sets';
|
|
8
8
|
import type { AssetCollectionTransferPackage, AssetManifestEntry } from '@handoff/registry/assets/transfer';
|
|
9
|
-
import type {
|
|
9
|
+
import type { StorageProvider } from '@handoff/registry/asset-storage/types';
|
|
10
10
|
import { singleQueryValue } from '../api/query';
|
|
11
|
-
import { getActiveAssetStorage,
|
|
11
|
+
import { getActiveAssetStorage, getActiveStorageProvider, getStorageProvider } from '../asset-storage';
|
|
12
12
|
import { sendRegistryError } from './errors';
|
|
13
13
|
import { buildMeta } from './meta';
|
|
14
14
|
import { handleRegistryRoute, sendRegistryData } from './handler';
|
|
@@ -318,10 +318,10 @@ export const handleAssetBlobRoute = (req: NextApiRequest, res: NextApiResponse):
|
|
|
318
318
|
'application/octet-stream';
|
|
319
319
|
const active = getActiveAssetStorage();
|
|
320
320
|
|
|
321
|
-
const storageProvider = active.
|
|
321
|
+
const storageProvider = active.provider;
|
|
322
322
|
let content: Buffer | null = null;
|
|
323
323
|
let storageRef: string | null = null;
|
|
324
|
-
if (active.
|
|
324
|
+
if (active.provider === 'database') {
|
|
325
325
|
if (bytes.length > active.maxInlineBytes) {
|
|
326
326
|
sendRegistryError(
|
|
327
327
|
res,
|
|
@@ -332,12 +332,12 @@ export const handleAssetBlobRoute = (req: NextApiRequest, res: NextApiResponse):
|
|
|
332
332
|
}
|
|
333
333
|
content = bytes;
|
|
334
334
|
} else {
|
|
335
|
-
const
|
|
336
|
-
if (!
|
|
337
|
-
sendRegistryError(res, 'unexpected_error', `No storage
|
|
335
|
+
const storage = await getActiveStorageProvider();
|
|
336
|
+
if (!storage) {
|
|
337
|
+
sendRegistryError(res, 'unexpected_error', `No asset storage is available for provider "${active.provider}".`);
|
|
338
338
|
return;
|
|
339
339
|
}
|
|
340
|
-
({ storageRef } = await
|
|
340
|
+
({ storageRef } = await storage.put({ hash, bytes, contentType, size: bytes.length }));
|
|
341
341
|
}
|
|
342
342
|
|
|
343
343
|
await db
|
|
@@ -376,12 +376,12 @@ export const handleAssetBlobRoute = (req: NextApiRequest, res: NextApiResponse):
|
|
|
376
376
|
sendRegistryError(res, 'not_found', `Blob "${hash}" has no resolvable content.`);
|
|
377
377
|
return;
|
|
378
378
|
}
|
|
379
|
-
const
|
|
380
|
-
if (!
|
|
381
|
-
sendRegistryError(res, 'unexpected_error', `No storage
|
|
379
|
+
const storage = await getStorageProvider(blob.storageProvider);
|
|
380
|
+
if (!storage) {
|
|
381
|
+
sendRegistryError(res, 'unexpected_error', `No asset storage is available for provider "${blob.storageProvider}".`);
|
|
382
382
|
return;
|
|
383
383
|
}
|
|
384
|
-
const result = await
|
|
384
|
+
const result = await storage.get(blob.storageRef);
|
|
385
385
|
if (result.kind === 'redirect') {
|
|
386
386
|
res.redirect(302, result.url);
|
|
387
387
|
return;
|
|
@@ -394,13 +394,13 @@ export const handleAssetBlobRoute = (req: NextApiRequest, res: NextApiResponse):
|
|
|
394
394
|
result.stream.pipe(res);
|
|
395
395
|
});
|
|
396
396
|
|
|
397
|
-
/** The active
|
|
398
|
-
const
|
|
399
|
-
if (getActiveAssetStorage().
|
|
397
|
+
/** The active provider when it supports direct uploads, otherwise `null` (the client then uses `PUT`). */
|
|
398
|
+
const getDirectUploadProvider = async (): Promise<StorageProvider | null> => {
|
|
399
|
+
if (getActiveAssetStorage().provider === 'database') {
|
|
400
400
|
return null;
|
|
401
401
|
}
|
|
402
|
-
const
|
|
403
|
-
return
|
|
402
|
+
const storage = await getActiveStorageProvider();
|
|
403
|
+
return storage?.createUpload ? storage : null;
|
|
404
404
|
};
|
|
405
405
|
|
|
406
406
|
const readDirectUploadRequest = (req: NextApiRequest, res: NextApiResponse): { hash: string; size: number; contentType: string } | null => {
|
|
@@ -422,8 +422,8 @@ const readDirectUploadRequest = (req: NextApiRequest, res: NextApiResponse): { h
|
|
|
422
422
|
* Throw unless `storageRef` resolves to a stored object. A redirect is checked with a one-byte
|
|
423
423
|
* ranged `GET`, because a signed read URL usually rejects `HEAD`.
|
|
424
424
|
*/
|
|
425
|
-
const assertStored = async (
|
|
426
|
-
const result = await
|
|
425
|
+
const assertStored = async (storage: StorageProvider, storageRef: string, size: number): Promise<void> => {
|
|
426
|
+
const result = await storage.get(storageRef);
|
|
427
427
|
if (result.kind === 'bytes') {
|
|
428
428
|
if (result.bytes.length !== size) {
|
|
429
429
|
throw new Error(`stored size ${result.bytes.length} does not match ${size}`);
|
|
@@ -452,8 +452,8 @@ export const handleAssetBlobUploadRoute = (req: NextApiRequest, res: NextApiResp
|
|
|
452
452
|
sendRegistryData(res, 200, { upload: null, stored: true }, buildMeta());
|
|
453
453
|
return;
|
|
454
454
|
}
|
|
455
|
-
const
|
|
456
|
-
const upload =
|
|
455
|
+
const storage = await getDirectUploadProvider();
|
|
456
|
+
const upload = storage?.createUpload ? await storage.createUpload(input) : null;
|
|
457
457
|
sendRegistryData(res, 200, { upload }, buildMeta());
|
|
458
458
|
});
|
|
459
459
|
|
|
@@ -468,13 +468,13 @@ export const handleAssetBlobCompleteRoute = (req: NextApiRequest, res: NextApiRe
|
|
|
468
468
|
return;
|
|
469
469
|
}
|
|
470
470
|
|
|
471
|
-
const
|
|
472
|
-
if (!
|
|
471
|
+
const storage = await getDirectUploadProvider();
|
|
472
|
+
if (!storage) {
|
|
473
473
|
sendRegistryError(res, 'bad_request', 'The active asset storage provider does not accept direct uploads.');
|
|
474
474
|
return;
|
|
475
475
|
}
|
|
476
476
|
try {
|
|
477
|
-
await assertStored(
|
|
477
|
+
await assertStored(storage, storageRef, input.size);
|
|
478
478
|
} catch (error) {
|
|
479
479
|
const reason = error instanceof Error ? error.message : String(error);
|
|
480
480
|
sendRegistryError(res, 'bad_request', `Blob "${input.hash}" was not found in storage (${reason}).`, {
|
|
@@ -487,7 +487,7 @@ export const handleAssetBlobCompleteRoute = (req: NextApiRequest, res: NextApiRe
|
|
|
487
487
|
.insert(assetBlobs)
|
|
488
488
|
.values({
|
|
489
489
|
hash: input.hash,
|
|
490
|
-
storageProvider: getActiveAssetStorage().
|
|
490
|
+
storageProvider: getActiveAssetStorage().provider,
|
|
491
491
|
content: null,
|
|
492
492
|
storageRef,
|
|
493
493
|
contentType: input.contentType,
|
|
@@ -4,7 +4,7 @@ import { pathToFileURL } from 'url';
|
|
|
4
4
|
import { resolveEnvReferences } from '@handoff/config/from-env';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Import a consumer's server-only module (a custom asset storage
|
|
7
|
+
* Import a consumer's server-only module (a custom asset storage, AI or email
|
|
8
8
|
* provider) by the path the config gives, relative to the project.
|
|
9
9
|
*
|
|
10
10
|
* A registry bundle mirrors the tracing root and runs with its root as the working directory, so the
|
package/src/app/lib/utils.ts
CHANGED
|
@@ -51,3 +51,10 @@ export const filterOutNull = <T>(value: T): value is NonNullable<T> => value !==
|
|
|
51
51
|
*/
|
|
52
52
|
export const hasRenderableNav = (section?: SectionLink | null): boolean =>
|
|
53
53
|
!!section?.subSections?.some((sub) => (sub.menu && sub.menu.length > 0) || !!sub.dynamic);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The browser title: a page's own `metaTitle` as written, otherwise its title followed by the
|
|
57
|
+
* site title from `app.title`.
|
|
58
|
+
*/
|
|
59
|
+
export const pageTitle = (metadata: { title?: string; metaTitle?: string }, siteTitle?: string): string =>
|
|
60
|
+
metadata.metaTitle || [metadata.title, siteTitle].filter(Boolean).join(' | ');
|
package/src/app/next.config.mjs
CHANGED
|
@@ -113,9 +113,9 @@ const nextConfig = {
|
|
|
113
113
|
HANDOFF_AI_DEFAULT_MODEL: '%HANDOFF_AI_DEFAULT_MODEL%',
|
|
114
114
|
HANDOFF_REGISTRY_DRIVER: '%HANDOFF_REGISTRY_DRIVER%',
|
|
115
115
|
HANDOFF_REGISTRY_DATABASE_URL_ENV: '%HANDOFF_REGISTRY_DATABASE_URL_ENV%',
|
|
116
|
-
// Asset storage
|
|
116
|
+
// Asset storage provider, module, and options JSON. Option references keep only their variable
|
|
117
117
|
// name and are resolved at request time, so secret values are never baked.
|
|
118
|
-
|
|
118
|
+
HANDOFF_ASSET_STORAGE_PROVIDER: '%HANDOFF_ASSET_STORAGE_PROVIDER%',
|
|
119
119
|
HANDOFF_ASSET_STORAGE_MODULE: '%HANDOFF_ASSET_STORAGE_MODULE%',
|
|
120
120
|
HANDOFF_ASSET_STORAGE_MAX_INLINE_BYTES: '%HANDOFF_ASSET_STORAGE_MAX_INLINE_BYTES%',
|
|
121
121
|
HANDOFF_ASSET_STORAGE_OPTIONS: '%HANDOFF_ASSET_STORAGE_OPTIONS%',
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from '../../components/util';
|
|
21
21
|
import { resolveDocsBackend } from '../../lib/docs-api/backend';
|
|
22
22
|
import { documentationMetadata, REGISTRY_PAGE_REVALIDATE_SECONDS } from '../../lib/docs-api/page-rendering';
|
|
23
|
+
import { pageTitle } from '../../lib/utils';
|
|
23
24
|
import { Home, getStaticProps as getHomeStaticProps } from '../index';
|
|
24
25
|
|
|
25
26
|
type CatchAllDocumentationProps = DocumentationProps & { homeAlias?: boolean };
|
|
@@ -114,7 +115,7 @@ export default function DocCatchAllPage({
|
|
|
114
115
|
return (
|
|
115
116
|
<div className="flex min-h-screen flex-col items-center justify-center bg-white dark:bg-gray-900">
|
|
116
117
|
<Head>
|
|
117
|
-
<title>
|
|
118
|
+
<title>{pageTitle({ title: 'Page Not Found' }, config?.app?.title)}</title>
|
|
118
119
|
<meta name="description" content="Page Not Found" />
|
|
119
120
|
</Head>
|
|
120
121
|
<NotFound />
|
|
@@ -34,7 +34,7 @@ export const getStaticProps: GetStaticProps = async () => ({ props: { config: ge
|
|
|
34
34
|
|
|
35
35
|
export default function AiKeysPage({ config }: { config: ClientConfig }) {
|
|
36
36
|
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
37
|
-
return <AccountLayout title="AI providers">{null}</AccountLayout>;
|
|
37
|
+
return <AccountLayout config={config} title="AI providers">{null}</AccountLayout>;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
return <RegistryAiKeysPage config={config} />;
|
|
@@ -25,7 +25,7 @@ export const getStaticProps: GetStaticProps = async () => ({ props: { config: ge
|
|
|
25
25
|
|
|
26
26
|
export default function AccountPage({ config }: { config: ClientConfig }) {
|
|
27
27
|
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
28
|
-
return <AccountLayout title="Profile">{null}</AccountLayout>;
|
|
28
|
+
return <AccountLayout config={config} title="Profile">{null}</AccountLayout>;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
return <RegistryAccountPage config={config} />;
|
|
@@ -37,7 +37,7 @@ export const getStaticProps: GetStaticProps = async () => ({ props: { config: ge
|
|
|
37
37
|
|
|
38
38
|
export default function TokensPage({ config }: { config: ClientConfig }) {
|
|
39
39
|
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
40
|
-
return <AccountLayout title="Access tokens">{null}</AccountLayout>;
|
|
40
|
+
return <AccountLayout config={config} title="Access tokens">{null}</AccountLayout>;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
return <RegistryTokensPage config={config} />;
|
|
@@ -37,7 +37,7 @@ export const getStaticProps: GetStaticProps = async () => ({ props: { config: ge
|
|
|
37
37
|
|
|
38
38
|
export default function UsersPage({ config }: { config: ClientConfig }) {
|
|
39
39
|
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
40
|
-
return <AccountLayout title="Users">{null}</AccountLayout>;
|
|
40
|
+
return <AccountLayout config={config} title="Users">{null}</AccountLayout>;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
return <RegistryUsersPage config={config} />;
|
|
@@ -5,7 +5,7 @@ import { ensureGet, resolveDocsBackend, sendDocsData, sendDocsError } from '@/li
|
|
|
5
5
|
// Kept static so the baked structure is traced into every registry serverless bundle.
|
|
6
6
|
import navShell from '@/generated/nav-shell.json';
|
|
7
7
|
|
|
8
|
-
/** Thin
|
|
8
|
+
/** Thin route: `getRegistryNavData` builds and fills the whole navigation. */
|
|
9
9
|
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
|
10
10
|
if (!ensureGet(req, res)) return;
|
|
11
11
|
|
package/src/app/pages/api/mcp.ts
CHANGED
|
@@ -22,6 +22,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|
|
22
22
|
return;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// The status probe of the docs app. A 204 shows only that the route is served, so the probe needs no credential.
|
|
26
|
+
if (req.method === 'HEAD') {
|
|
27
|
+
res.status(204).end();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
if (!(await authorizeMcpRequest(req, res))) {
|
|
26
32
|
return;
|
|
27
33
|
}
|
|
@@ -5,7 +5,7 @@ import { handleAssetBlobRoute } from '@/lib/registry-api/asset-transfer';
|
|
|
5
5
|
* `/api/registry/transfer/assets/blobs/:hash`: one content-addressed blob.
|
|
6
6
|
*
|
|
7
7
|
* - `PUT` stores the raw binary body through the active storage provider (inline `bytea` by default,
|
|
8
|
-
* or
|
|
8
|
+
* or a custom storage provider). Idempotent by hash; requires a `registry:write` token.
|
|
9
9
|
* - `GET` returns the blob's bytes, or redirects to a provider URL for object-backed content.
|
|
10
10
|
* Requires a `registry:read` token.
|
|
11
11
|
*
|
|
@@ -20,7 +20,7 @@ export const getStaticProps: GetStaticProps = async () => ({ props: { config: ge
|
|
|
20
20
|
|
|
21
21
|
export default function DeviceApprovalPage({ config }: { config: ClientConfig }) {
|
|
22
22
|
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
23
|
-
return <AuthShell title="Authorize CLI">{null}</AuthShell>;
|
|
23
|
+
return <AuthShell config={config} title="Authorize CLI">{null}</AuthShell>;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
return <RegistryDeviceApprovalPage config={config} />;
|
|
@@ -33,6 +33,10 @@ export const getStaticProps: GetStaticProps = async () => {
|
|
|
33
33
|
};
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
+
// Tokens without a Figma group land under an empty key.
|
|
37
|
+
const groupTitle = (group: string) => upperFirst(group) || 'Other';
|
|
38
|
+
const groupId = (group: string) => group || 'other';
|
|
39
|
+
|
|
36
40
|
const ColorsPage = ({
|
|
37
41
|
content,
|
|
38
42
|
menu,
|
|
@@ -82,20 +86,17 @@ const ColorsPage = ({
|
|
|
82
86
|
<div className="lg:gap-10 lg:py-8 xl:grid xl:grid-cols-[1fr_280px]">
|
|
83
87
|
<div className="flex flex-col gap-0">
|
|
84
88
|
{Object.keys(colorGroups).map((group) => (
|
|
85
|
-
<ColorGrid
|
|
86
|
-
title={upperFirst(group)}
|
|
87
|
-
group={group}
|
|
88
|
-
description="Colors that are used most frequently across all pages and components."
|
|
89
|
-
colors={colorGroups[group]}
|
|
90
|
-
key={group}
|
|
91
|
-
/>
|
|
89
|
+
<ColorGrid title={groupTitle(group)} group={groupId(group)} colors={colorGroups[group]} key={group} />
|
|
92
90
|
))}
|
|
93
91
|
<PrevNextNav previous={null} next={{ title: 'Typography', href: '/foundations/typography' }} />
|
|
94
92
|
</div>
|
|
95
93
|
|
|
96
94
|
<AnchorNav
|
|
97
95
|
groups={[
|
|
98
|
-
Object.assign(
|
|
96
|
+
Object.assign(
|
|
97
|
+
{},
|
|
98
|
+
...[...Object.keys(colorGroups).map((group) => ({ [`${groupId(group)}-colors`]: `${groupTitle(group)} Colors` }))]
|
|
99
|
+
),
|
|
99
100
|
]}
|
|
100
101
|
/>
|
|
101
102
|
|
|
@@ -50,7 +50,7 @@ const DesignPage = ({ content, menu, metadata, current, config }: DocumentationP
|
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
52
|
title: 'Colors',
|
|
53
|
-
description: 'Official
|
|
53
|
+
description: 'Official color palette used for all digital products.',
|
|
54
54
|
icon: Palette,
|
|
55
55
|
link: '/foundations/colors',
|
|
56
56
|
cta: 'Explore Colors',
|
|
@@ -74,14 +74,14 @@ const DesignPage = ({ content, menu, metadata, current, config }: DocumentationP
|
|
|
74
74
|
description: 'Downloadable icon set for use in digital and offline materials.',
|
|
75
75
|
icon: Shapes,
|
|
76
76
|
link: '/foundations/icons',
|
|
77
|
-
cta: '
|
|
77
|
+
cta: 'Explore Icons',
|
|
78
78
|
},
|
|
79
79
|
{
|
|
80
80
|
title: 'Effects',
|
|
81
81
|
description: 'Shadows, blurs, and other effects used in the design system.',
|
|
82
82
|
icon: Sun,
|
|
83
83
|
link: '/foundations/effects',
|
|
84
|
-
cta: '
|
|
84
|
+
cta: 'Explore Effects',
|
|
85
85
|
},
|
|
86
86
|
]}
|
|
87
87
|
/>
|
package/src/app/pages/index.tsx
CHANGED
|
@@ -57,11 +57,11 @@ export const getStaticProps: GetStaticProps = async (context) => {
|
|
|
57
57
|
|
|
58
58
|
export const Home = ({ content, menu, metadata, config, current }: DocumentationProps) => {
|
|
59
59
|
return (
|
|
60
|
-
<Layout config={config} menu={menu} current={current} metadata={metadata} fullWidthHero={true}>
|
|
60
|
+
<Layout config={config} menu={menu} current={current} metadata={{ ...metadata, metaTitle: metadata.metaTitle || config?.app?.title }} fullWidthHero={true}>
|
|
61
61
|
<div className="w-full bg-linear-to-r py-12 dark:from-gray-900 dark:to-gray-800 sm:py-20">
|
|
62
62
|
<div className="container mx-auto px-8">
|
|
63
63
|
<HeadersType.H1 className="max-w-4xl text-3xl font-semibold leading-[-0.05px] sm:text-4xl">
|
|
64
|
-
{config?.app?.
|
|
64
|
+
{config?.app?.title}
|
|
65
65
|
</HeadersType.H1>
|
|
66
66
|
{content && (
|
|
67
67
|
<div className="text-lg max-w-4xl empty:hidden [&>*]:mt-6 [&>p]:font-light [&>h2:first-child]:mt-0 [&>h2:first-child]:font-normal sm:text-xl">
|
package/src/app/pages/login.tsx
CHANGED
|
@@ -2,29 +2,34 @@ import { Button } from '../components/ui/button';
|
|
|
2
2
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../components/ui/card';
|
|
3
3
|
import { Input } from '../components/ui/input';
|
|
4
4
|
import { Label } from '../components/ui/label';
|
|
5
|
+
import type { ClientConfig } from '@handoff/types/config';
|
|
6
|
+
import type { GetStaticProps } from 'next';
|
|
5
7
|
import Link from 'next/link';
|
|
6
8
|
import { signIn, useSession } from 'next-auth/react';
|
|
7
9
|
import { useRouter } from 'next/router';
|
|
8
10
|
import { FormEvent, useEffect, useState } from 'react';
|
|
9
11
|
import { AuthShell } from '../components/Auth/AuthShell';
|
|
10
12
|
import { loadInstallStatus } from '../components/Auth/installStatus';
|
|
13
|
+
import { getClientRuntimeConfig } from '../components/util';
|
|
11
14
|
|
|
12
15
|
const safeCallbackUrl = (value: unknown): string =>
|
|
13
16
|
typeof value === 'string' && value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\') ? value : '/';
|
|
14
17
|
|
|
15
|
-
export
|
|
18
|
+
export const getStaticProps: GetStaticProps = async () => ({ props: { config: getClientRuntimeConfig() } });
|
|
19
|
+
|
|
20
|
+
export default function LoginPage({ config }: { config: ClientConfig }) {
|
|
16
21
|
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
17
22
|
return (
|
|
18
|
-
<AuthShell title="Sign in" centered>
|
|
23
|
+
<AuthShell config={config} title="Sign in" centered>
|
|
19
24
|
{null}
|
|
20
25
|
</AuthShell>
|
|
21
26
|
);
|
|
22
27
|
}
|
|
23
28
|
|
|
24
|
-
return <RegistryLoginPage />;
|
|
29
|
+
return <RegistryLoginPage config={config} />;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
|
-
function RegistryLoginPage() {
|
|
32
|
+
function RegistryLoginPage({ config }: { config: ClientConfig }) {
|
|
28
33
|
const router = useRouter();
|
|
29
34
|
const { data: session, status } = useSession();
|
|
30
35
|
const [pending, setPending] = useState(false);
|
|
@@ -77,7 +82,7 @@ function RegistryLoginPage() {
|
|
|
77
82
|
};
|
|
78
83
|
|
|
79
84
|
return (
|
|
80
|
-
<AuthShell title="Sign in" centered>
|
|
85
|
+
<AuthShell config={config} title="Sign in" centered>
|
|
81
86
|
<Card>
|
|
82
87
|
<CardHeader>
|
|
83
88
|
<CardTitle>Sign in</CardTitle>
|
|
@@ -2,14 +2,19 @@ import { Button } from '../components/ui/button';
|
|
|
2
2
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../components/ui/card';
|
|
3
3
|
import { Input } from '../components/ui/input';
|
|
4
4
|
import { Label } from '../components/ui/label';
|
|
5
|
+
import type { ClientConfig } from '@handoff/types/config';
|
|
6
|
+
import type { GetStaticProps } from 'next';
|
|
5
7
|
import Head from 'next/head';
|
|
6
8
|
import Link from 'next/link';
|
|
7
9
|
import { useRouter } from 'next/router';
|
|
8
10
|
import { FormEvent, useEffect, useState } from 'react';
|
|
9
11
|
import { AuthShell } from '../components/Auth/AuthShell';
|
|
10
12
|
import { authApiUrl, readApiError } from '../components/Auth/api';
|
|
13
|
+
import { getClientRuntimeConfig } from '../components/util';
|
|
11
14
|
|
|
12
|
-
export
|
|
15
|
+
export const getStaticProps: GetStaticProps = async () => ({ props: { config: getClientRuntimeConfig() } });
|
|
16
|
+
|
|
17
|
+
export default function ResetPasswordPage({ config }: { config: ClientConfig }) {
|
|
13
18
|
const router = useRouter();
|
|
14
19
|
const [token, setToken] = useState('');
|
|
15
20
|
const [purpose, setPurpose] = useState<'invite' | 'reset'>('reset');
|
|
@@ -87,7 +92,7 @@ export default function ResetPasswordPage() {
|
|
|
87
92
|
};
|
|
88
93
|
|
|
89
94
|
return (
|
|
90
|
-
<AuthShell title={token ? 'Set a new password' : 'Reset password'} centered>
|
|
95
|
+
<AuthShell config={config} title={token ? 'Set a new password' : 'Reset password'} centered>
|
|
91
96
|
<Head>
|
|
92
97
|
<meta name="referrer" content="no-referrer" />
|
|
93
98
|
</Head>
|
|
@@ -96,9 +96,6 @@ export const getStaticProps = async (context) => {
|
|
|
96
96
|
const previousComponent = sameGroupComponents[groupIndex - 1] ?? null;
|
|
97
97
|
const nextComponent = sameGroupComponents[groupIndex + 1] ?? null;
|
|
98
98
|
|
|
99
|
-
const fallbackTitle = componentData.title || startCase(component as string);
|
|
100
|
-
const fallbackMetaTitle = `${fallbackTitle}${config?.app?.client ? ` | ${config.app.client} Design System` : ''}`;
|
|
101
|
-
|
|
102
99
|
return {
|
|
103
100
|
props: {
|
|
104
101
|
id: component,
|
|
@@ -110,7 +107,7 @@ export const getStaticProps = async (context) => {
|
|
|
110
107
|
...componentData,
|
|
111
108
|
title: componentData.title || docs.metadata.title || startCase(component as string),
|
|
112
109
|
description: componentData.description,
|
|
113
|
-
metaTitle: docs.metadata.metaTitle
|
|
110
|
+
metaTitle: docs.metadata.metaTitle ?? '',
|
|
114
111
|
metaDescription: docs.metadata.metaDescription || componentData.description,
|
|
115
112
|
image: docs.metadata.image || 'hero-brand-assets',
|
|
116
113
|
},
|
|
@@ -256,7 +253,7 @@ const GenericComponentPage = ({ menu, metadata, current, id, config, componentHo
|
|
|
256
253
|
</Button>
|
|
257
254
|
)}
|
|
258
255
|
<Drawer direction="right">
|
|
259
|
-
<DrawerTrigger>
|
|
256
|
+
<DrawerTrigger asChild>
|
|
260
257
|
<Button variant="outline" size={'sm'} className="font-normal [&_svg]:size-3!">
|
|
261
258
|
API Reference
|
|
262
259
|
</Button>
|
|
@@ -107,20 +107,20 @@ const ComponentsPage = ({ content, menu, metadata, current, config }: ComponentP
|
|
|
107
107
|
<div className="mt-3 flex flex-row justify-between gap-3">
|
|
108
108
|
<p className="text-lg leading-relaxed text-gray-600 dark:text-gray-300">{metadata.description}</p>
|
|
109
109
|
<Drawer direction="right">
|
|
110
|
-
<
|
|
111
|
-
<
|
|
112
|
-
<
|
|
113
|
-
<
|
|
110
|
+
<TooltipProvider>
|
|
111
|
+
<Tooltip>
|
|
112
|
+
<TooltipTrigger asChild>
|
|
113
|
+
<DrawerTrigger asChild>
|
|
114
114
|
<Button variant="outline">
|
|
115
115
|
API <Webhook strokeWidth={1.5} />
|
|
116
116
|
</Button>
|
|
117
|
-
</
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
</
|
|
121
|
-
</
|
|
122
|
-
</
|
|
123
|
-
</
|
|
117
|
+
</DrawerTrigger>
|
|
118
|
+
</TooltipTrigger>
|
|
119
|
+
<TooltipContent side="left">
|
|
120
|
+
<Badge>{apiUrl}</Badge>
|
|
121
|
+
</TooltipContent>
|
|
122
|
+
</Tooltip>
|
|
123
|
+
</TooltipProvider>
|
|
124
124
|
<DrawerContent>
|
|
125
125
|
<div className="mx-5 w-full max-w-lg">
|
|
126
126
|
<DrawerHeader>
|