handoff-app 1.1.3 → 1.2.0-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/config/config.template.js +21 -14
- package/config/config.template.ts +54 -0
- package/config/docs/system.md +2 -0
- package/dist/app-builder/build.js +7 -2
- package/dist/app-builder/config-diff/index.d.ts +4 -0
- package/dist/app-builder/config-diff/index.js +12 -0
- package/dist/app-builder/config-diff/registry.d.ts +9 -0
- package/dist/app-builder/config-diff/registry.js +30 -0
- package/dist/app-builder/config-diff/snapshot.d.ts +13 -0
- package/dist/app-builder/config-diff/snapshot.js +43 -0
- package/dist/app-builder/config-diff/strategies/pattern.d.ts +2 -0
- package/dist/app-builder/config-diff/strategies/pattern.js +93 -0
- package/dist/app-builder/config-diff/types.d.ts +19 -0
- package/dist/app-builder/config-diff/types.js +2 -0
- package/dist/app-builder/watchers/component.d.ts +15 -0
- package/dist/app-builder/watchers/component.js +69 -0
- package/dist/app-builder/watchers/utils.d.ts +14 -0
- package/dist/app-builder/watchers/utils.js +44 -0
- package/dist/app-builder/watchers.d.ts +10 -19
- package/dist/app-builder/watchers.js +56 -102
- package/dist/cache/build-cache.js +14 -5
- package/dist/cli/create.js +67 -23
- package/dist/cli/eject.js +48 -7
- package/dist/cli/make.js +29 -4
- package/dist/cli/scaffold.js +60 -19
- package/dist/cli.js +1 -1
- package/dist/config/helpers.d.ts +7 -0
- package/dist/config/helpers.js +31 -0
- package/dist/config/index.d.ts +2 -1
- package/dist/config/index.js +5 -1
- package/dist/config/loader.d.ts +10 -1
- package/dist/config/loader.js +92 -28
- package/dist/config/normalizers/declaration.d.ts +11 -0
- package/dist/config/normalizers/declaration.js +119 -0
- package/dist/config/normalizers/pattern.d.ts +9 -0
- package/dist/config/normalizers/pattern.js +38 -0
- package/dist/config/runtime.d.ts +9 -3
- package/dist/config/runtime.js +254 -33
- package/dist/declarations/helpers.d.ts +6 -0
- package/dist/declarations/helpers.js +19 -0
- package/dist/declarations/index.d.ts +2 -0
- package/dist/declarations/index.js +9 -0
- package/dist/declarations/types.d.ts +60 -0
- package/dist/declarations/types.js +2 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.js +34 -5
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +3 -1
- package/dist/pipeline/patterns.d.ts +6 -0
- package/dist/pipeline/patterns.js +21 -0
- package/dist/transformers/docgen/index.js +12 -2
- package/dist/transformers/plugins/csf-render.js +170 -34
- package/dist/transformers/plugins/ssr-render.js +18 -2
- package/dist/transformers/preview/component/api.js +16 -7
- package/dist/transformers/preview/component/builder.js +9 -3
- package/dist/transformers/preview/component/css.d.ts +8 -0
- package/dist/transformers/preview/component/html.js +14 -5
- package/dist/transformers/preview/component/previews.d.ts +9 -0
- package/dist/transformers/preview/component/previews.js +10 -0
- package/dist/transformers/preview/pattern/api.d.ts +7 -0
- package/dist/transformers/preview/pattern/api.js +61 -0
- package/dist/transformers/preview/pattern/builder.d.ts +9 -0
- package/dist/transformers/preview/pattern/builder.js +86 -0
- package/dist/transformers/preview/pattern/html.d.ts +19 -0
- package/dist/transformers/preview/pattern/html.js +94 -0
- package/dist/transformers/preview/types.d.ts +54 -0
- package/dist/transformers/utils/csf.d.ts +16 -0
- package/dist/transformers/utils/csf.js +30 -0
- package/dist/transformers/utils/index.d.ts +1 -0
- package/dist/transformers/utils/index.js +2 -0
- package/dist/transformers/utils/module.d.ts +5 -0
- package/dist/transformers/utils/module.js +26 -13
- package/dist/transformers/utils/usage.d.ts +23 -0
- package/dist/transformers/utils/usage.js +294 -0
- package/dist/types/config.d.ts +32 -7
- package/dist/types/preview.d.ts +2 -0
- package/docs/cli.md +3 -3
- package/package.json +3 -1
- package/src/app/components/Component/PageSliceResolver.tsx +6 -6
- package/src/app/components/Component/Preview.tsx +17 -12
- package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +16 -6
- package/src/app/components/Markdown/CodeHighlight.tsx +60 -11
- package/src/app/components/util/index.ts +71 -2
- package/src/app/pages/system/pattern/[pattern]/index.tsx +223 -0
- package/src/app/pages/system/pattern/index.tsx +14 -0
- package/src/app-builder/README.md +37 -9
- package/src/app-builder/build.ts +8 -2
- package/src/app-builder/config-diff/index.ts +4 -0
- package/src/app-builder/config-diff/registry.ts +23 -0
- package/src/app-builder/config-diff/snapshot.ts +51 -0
- package/src/app-builder/config-diff/strategies/pattern.ts +65 -0
- package/src/app-builder/config-diff/types.ts +21 -0
- package/src/app-builder/watchers/component.ts +72 -0
- package/src/app-builder/watchers/utils.ts +44 -0
- package/src/app-builder/watchers.ts +62 -80
- package/src/cache/build-cache.ts +14 -5
- package/src/cli/create.ts +86 -25
- package/src/cli/eject.ts +29 -7
- package/src/cli/make.ts +32 -5
- package/src/cli/scaffold.ts +68 -21
- package/src/cli.ts +1 -1
- package/src/config/README.md +10 -1
- package/src/config/helpers.ts +43 -0
- package/src/config/index.ts +4 -1
- package/src/config/loader.ts +110 -31
- package/src/config/normalizers/declaration.ts +176 -0
- package/src/config/normalizers/pattern.ts +63 -0
- package/src/config/runtime.ts +310 -35
- package/src/declarations/helpers.ts +36 -0
- package/src/declarations/index.ts +19 -0
- package/src/declarations/types.ts +70 -0
- package/src/index.ts +47 -6
- package/src/pipeline/index.ts +1 -0
- package/src/pipeline/patterns.ts +10 -0
- package/src/transformers/README.md +2 -2
- package/src/transformers/docgen/index.ts +13 -2
- package/src/transformers/plugins/csf-render.ts +216 -49
- package/src/transformers/plugins/ssr-render.ts +23 -7
- package/src/transformers/preview/component/api.ts +27 -7
- package/src/transformers/preview/component/builder.ts +21 -4
- package/src/transformers/preview/component/html.ts +18 -5
- package/src/transformers/preview/component/previews.ts +12 -0
- package/src/transformers/preview/pattern/api.ts +66 -0
- package/src/transformers/preview/pattern/builder.ts +94 -0
- package/src/transformers/preview/pattern/html.ts +107 -0
- package/src/transformers/preview/types.ts +58 -1
- package/src/transformers/utils/csf.ts +48 -0
- package/src/transformers/utils/index.ts +3 -0
- package/src/transformers/utils/module.ts +33 -18
- package/src/transformers/utils/usage.ts +319 -0
- package/src/types/README.md +1 -1
- package/src/types/config.ts +33 -7
- package/src/types/preview.ts +2 -0
- package/templates/default/components/button/Button.jsx.tpl +64 -0
- package/templates/default/components/button/button.react.handoff.js.tpl +31 -0
- package/templates/default/components/button/button.react.handoff.ts.tpl +31 -0
- package/templates/default/handoff.config.js.tpl +29 -16
- package/templates/default/handoff.config.ts.tpl +196 -0
- package/templates/default/handoff.config.with-examples.js.tpl +28 -17
- package/templates/default/handoff.config.with-examples.ts.tpl +195 -0
- package/templates/default/package.json.tpl +1 -3
- package/handoff.config.js +0 -90
- package/templates/default/components/button/button.js.tpl +0 -62
|
@@ -2,7 +2,7 @@ import { PreviewObject } from '@handoff/types/preview';
|
|
|
2
2
|
// @ts-ignore
|
|
3
3
|
import { CollapsibleTrigger } from '@radix-ui/react-collapsible';
|
|
4
4
|
import { Select } from '@radix-ui/react-select';
|
|
5
|
-
import { useEffect, useState } from 'react';
|
|
5
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
6
6
|
import handlebars from 'react-syntax-highlighter/dist/esm/languages/prism/handlebars';
|
|
7
7
|
import js from 'react-syntax-highlighter/dist/esm/languages/prism/javascript';
|
|
8
8
|
import json from 'react-syntax-highlighter/dist/esm/languages/prism/json';
|
|
@@ -47,8 +47,8 @@ export const CodeHighlight: React.FC<{
|
|
|
47
47
|
title?: string;
|
|
48
48
|
language?: string;
|
|
49
49
|
height?: string;
|
|
50
|
-
|
|
51
|
-
}> = ({ data, collapsible, type, title, dark, height,
|
|
50
|
+
currentPreviewUrl?: string;
|
|
51
|
+
}> = ({ data, collapsible, type, title, dark, height, currentPreviewUrl }) => {
|
|
52
52
|
const [isOpen, setIsOpen] = useState<boolean>(true);
|
|
53
53
|
|
|
54
54
|
if (!data) {
|
|
@@ -69,7 +69,7 @@ export const CodeHighlight: React.FC<{
|
|
|
69
69
|
format: 'html',
|
|
70
70
|
};
|
|
71
71
|
} else if (typeof data === 'string') {
|
|
72
|
-
data =
|
|
72
|
+
data = {
|
|
73
73
|
id: '',
|
|
74
74
|
title: '',
|
|
75
75
|
categories: [],
|
|
@@ -91,13 +91,26 @@ export const CodeHighlight: React.FC<{
|
|
|
91
91
|
const metadataKeys = [
|
|
92
92
|
'id', 'preview', 'image', 'categories', 'title', 'format',
|
|
93
93
|
'description', 'type', 'group', 'tags', 'previews', 'properties',
|
|
94
|
-
'should_do', 'should_not_do', 'figma',
|
|
94
|
+
'should_do', 'should_not_do', 'figma', 'usage',
|
|
95
95
|
];
|
|
96
96
|
|
|
97
97
|
const states = Object.keys(data)
|
|
98
98
|
.filter((key) => !metadataKeys.includes(key) && !!(data as Record<string, any>)[key]);
|
|
99
|
-
const
|
|
99
|
+
const hasPerPreviewUsage =
|
|
100
|
+
typeof data === 'object' &&
|
|
101
|
+
!!data.previews &&
|
|
102
|
+
Object.values(data.previews).some((preview) => !!preview?.usage);
|
|
103
|
+
const hasUsageState = (typeof data === 'object' && !!data.usage) || hasPerPreviewUsage;
|
|
104
|
+
const selectableStates = hasUsageState && !states.includes('usage') ? ['usage', ...states] : states;
|
|
105
|
+
const preferredState = selectableStates.includes('usage') ? 'usage' : selectableStates[0];
|
|
106
|
+
const [activeState, setActiveState] = useState<string>(preferredState);
|
|
100
107
|
const [code, setCode] = useState<string>(data.html);
|
|
108
|
+
const stateResetKey = useMemo(() => {
|
|
109
|
+
if (typeof data === 'string') {
|
|
110
|
+
return `string:${type || 'html'}`;
|
|
111
|
+
}
|
|
112
|
+
return `${data.id || ''}|${data.title || ''}|${data.format || ''}|${Object.keys(data.previews || {}).join('|')}`;
|
|
113
|
+
}, [data, type]);
|
|
101
114
|
const theme = dark ? oneDark : oneLight;
|
|
102
115
|
theme['pre[class*="language-"]'].overflow = 'auto';
|
|
103
116
|
theme['pre[class*="language-"]'].maxHeight = height ?? '450px';
|
|
@@ -118,6 +131,7 @@ export const CodeHighlight: React.FC<{
|
|
|
118
131
|
sass: 'SASS',
|
|
119
132
|
scss: 'SCSS',
|
|
120
133
|
sharedStyles: 'Shared CSS',
|
|
134
|
+
usage: 'Usage',
|
|
121
135
|
json: 'JSON',
|
|
122
136
|
markdown: 'Markdown',
|
|
123
137
|
};
|
|
@@ -167,6 +181,7 @@ export const CodeHighlight: React.FC<{
|
|
|
167
181
|
css: 'css',
|
|
168
182
|
sass: 'scss',
|
|
169
183
|
sharedStyles: 'css',
|
|
184
|
+
usage: 'tsx',
|
|
170
185
|
json: 'json',
|
|
171
186
|
handlebars: 'handlebars',
|
|
172
187
|
hbs: 'handlebars',
|
|
@@ -195,12 +210,43 @@ export const CodeHighlight: React.FC<{
|
|
|
195
210
|
return jsxPatterns.some(pattern => pattern.test(code));
|
|
196
211
|
};
|
|
197
212
|
|
|
213
|
+
const resolveUsageCode = (): string => {
|
|
214
|
+
if (typeof data !== 'object') {
|
|
215
|
+
return '';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (currentPreviewUrl && data.previews) {
|
|
219
|
+
const activePreview = Object.values(data.previews).find((preview) => preview.url === currentPreviewUrl);
|
|
220
|
+
if (activePreview?.usage) {
|
|
221
|
+
return activePreview.usage;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const firstPreviewWithUsage = data.previews ? Object.values(data.previews).find((preview) => !!preview.usage) : undefined;
|
|
226
|
+
if (firstPreviewWithUsage?.usage) {
|
|
227
|
+
return firstPreviewWithUsage.usage;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return data.usage || '';
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// Always default to usage for newly viewed component/code payloads.
|
|
234
|
+
// Users can still manually switch tabs after this initial selection.
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
setActiveState(preferredState);
|
|
237
|
+
}, [preferredState, stateResetKey]);
|
|
238
|
+
|
|
198
239
|
useEffect(() => {
|
|
199
240
|
if (typeof data === 'string') {
|
|
200
241
|
setCode(data);
|
|
201
242
|
return;
|
|
202
243
|
}
|
|
203
244
|
|
|
245
|
+
if (activeState === 'usage') {
|
|
246
|
+
setCode(resolveUsageCode());
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
204
250
|
if (activeState === 'code' && 'code' in data && !!data.code) {
|
|
205
251
|
setCode(data.code);
|
|
206
252
|
return;
|
|
@@ -209,7 +255,7 @@ export const CodeHighlight: React.FC<{
|
|
|
209
255
|
if (activeState in data && !!(data as Record<string, any>)[activeState]) {
|
|
210
256
|
setCode((data as Record<string, any>)[activeState]);
|
|
211
257
|
}
|
|
212
|
-
}, [activeState,
|
|
258
|
+
}, [activeState, currentPreviewUrl, data]);
|
|
213
259
|
|
|
214
260
|
return (
|
|
215
261
|
<Collapsible id="code-samples" className="mt-4 space-y-2" style={{ maxWidth: '71vw' }} open={isOpen} onOpenChange={setIsOpen}>
|
|
@@ -219,14 +265,17 @@ export const CodeHighlight: React.FC<{
|
|
|
219
265
|
>
|
|
220
266
|
{title && <div>{title}</div>}
|
|
221
267
|
<div className="flex items-center gap-2">
|
|
222
|
-
{
|
|
268
|
+
{selectableStates.length > 1 && (
|
|
223
269
|
<Select
|
|
224
|
-
|
|
270
|
+
value={activeState}
|
|
225
271
|
onValueChange={(key) => {
|
|
226
272
|
setActiveState(key);
|
|
227
273
|
if (typeof data === 'string') {
|
|
228
274
|
setCode(data);
|
|
229
275
|
return;
|
|
276
|
+
} else if (key === 'usage') {
|
|
277
|
+
setCode(resolveUsageCode());
|
|
278
|
+
return;
|
|
230
279
|
} else {
|
|
231
280
|
setCode(data[key]);
|
|
232
281
|
}
|
|
@@ -236,8 +285,8 @@ export const CodeHighlight: React.FC<{
|
|
|
236
285
|
<SelectValue placeholder="Code View" />
|
|
237
286
|
</SelectTrigger>
|
|
238
287
|
<SelectContent>
|
|
239
|
-
{
|
|
240
|
-
.filter((value) => ['code', 'html', 'css', 'js', 'sass', 'sharedStyles'].includes(value))
|
|
288
|
+
{selectableStates
|
|
289
|
+
.filter((value) => ['usage', 'code', 'html', 'css', 'js', 'sass', 'sharedStyles'].includes(value))
|
|
241
290
|
.map((state) => {
|
|
242
291
|
return (
|
|
243
292
|
<SelectItem key={state} value={state}>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ComponentListObject, ComponentType } from '@handoff/transformers/preview/types';
|
|
1
|
+
import { ComponentListObject, ComponentType, PatternListObject } from '@handoff/transformers/preview/types';
|
|
2
2
|
import { ClientConfig, RuntimeConfig } from '@handoff/types/config';
|
|
3
3
|
import { ComponentDocumentationOptions, PreviewObject } from '@handoff/types/preview';
|
|
4
4
|
import * as fs from 'fs-extra';
|
|
@@ -85,7 +85,7 @@ export const knownPaths = [
|
|
|
85
85
|
'assets/logos',
|
|
86
86
|
'foundations',
|
|
87
87
|
'foundations/colors',
|
|
88
|
-
'foundations/icons',
|
|
88
|
+
'foundations/icons',
|
|
89
89
|
'foundations/effects',
|
|
90
90
|
'foundations/logos',
|
|
91
91
|
'foundations/logo',
|
|
@@ -98,6 +98,7 @@ export const knownPaths = [
|
|
|
98
98
|
'system/tokens/foundations/effects',
|
|
99
99
|
'system/tokens/foundations/typography',
|
|
100
100
|
'system/tokens/components',
|
|
101
|
+
'system/pattern',
|
|
101
102
|
];
|
|
102
103
|
|
|
103
104
|
/**
|
|
@@ -266,6 +267,16 @@ export const staticBuildMenu = () => {
|
|
|
266
267
|
menu: staticBuildTokensMenu(),
|
|
267
268
|
};
|
|
268
269
|
}
|
|
270
|
+
if (sub.patterns) {
|
|
271
|
+
const patternMenu = staticBuildPatternMenu();
|
|
272
|
+
if (patternMenu.length > 0) {
|
|
273
|
+
return {
|
|
274
|
+
title: sub.title || 'Patterns',
|
|
275
|
+
menu: patternMenu,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
269
280
|
if (sub.enabled !== false) {
|
|
270
281
|
return sub;
|
|
271
282
|
}
|
|
@@ -413,6 +424,64 @@ const staticBuildTokenMenu = () => {
|
|
|
413
424
|
return subSections;
|
|
414
425
|
};
|
|
415
426
|
|
|
427
|
+
/**
|
|
428
|
+
* Fetch patterns from the patterns.json API file
|
|
429
|
+
*/
|
|
430
|
+
export const fetchPatterns = (): { id: string; title: string; description: string; group: string }[] => {
|
|
431
|
+
const patternsFilePath = path.resolve(
|
|
432
|
+
process.env.HANDOFF_MODULE_PATH ?? '',
|
|
433
|
+
'.handoff',
|
|
434
|
+
`${process.env.HANDOFF_PROJECT_ID}`,
|
|
435
|
+
'public',
|
|
436
|
+
'api',
|
|
437
|
+
'patterns.json'
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
if (!fs.existsSync(patternsFilePath)) {
|
|
441
|
+
return [];
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
const patternList = JSON.parse(
|
|
446
|
+
fs.readFileSync(patternsFilePath, 'utf-8')
|
|
447
|
+
) as PatternListObject[];
|
|
448
|
+
|
|
449
|
+
return patternList.map((p) => ({
|
|
450
|
+
id: p.id,
|
|
451
|
+
title: p.title || '',
|
|
452
|
+
description: p.description || '',
|
|
453
|
+
group: p.group || '',
|
|
454
|
+
}));
|
|
455
|
+
} catch {
|
|
456
|
+
return [];
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const staticBuildPatternMenu = () => {
|
|
461
|
+
const basePath = buildBasePath();
|
|
462
|
+
const patterns = fetchPatterns();
|
|
463
|
+
|
|
464
|
+
if (patterns.length === 0) return [];
|
|
465
|
+
|
|
466
|
+
const grouped = groupBy(patterns, (p) => p.group ?? '');
|
|
467
|
+
let menu: { title: string; menu: { path: string; title: string }[] }[] = [];
|
|
468
|
+
|
|
469
|
+
Object.keys(grouped).forEach((group) => {
|
|
470
|
+
const menuGroup = { title: group || 'Uncategorized', menu: [] as { path: string; title: string }[] };
|
|
471
|
+
grouped[group].forEach((pattern) => {
|
|
472
|
+
menuGroup.menu.push({
|
|
473
|
+
path: `${basePath}system/pattern/${pattern.id}`,
|
|
474
|
+
title: pattern.title || startCase(pattern.id),
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
menuGroup.menu.sort((a, b) => a.title.localeCompare(b.title));
|
|
478
|
+
menu.push(menuGroup);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
menu.sort((a, b) => a.title.localeCompare(b.title));
|
|
482
|
+
return menu;
|
|
483
|
+
};
|
|
484
|
+
|
|
416
485
|
/**
|
|
417
486
|
* Filter the menus by the current path
|
|
418
487
|
* @param menu
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { PatternListObject } from '@handoff/transformers/preview/types';
|
|
3
|
+
import { startCase } from 'lodash';
|
|
4
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
5
|
+
import ReactMarkdown from 'react-markdown';
|
|
6
|
+
import rehypeRaw from 'rehype-raw';
|
|
7
|
+
import Layout from '../../../../components/Layout/Main';
|
|
8
|
+
import { MarkdownComponents } from '../../../../components/Markdown/MarkdownComponents';
|
|
9
|
+
import PrevNextNav from '../../../../components/Navigation/PrevNextNav';
|
|
10
|
+
import HeadersType from '../../../../components/Typography/Headers';
|
|
11
|
+
import {
|
|
12
|
+
DocumentationProps,
|
|
13
|
+
fetchPatterns,
|
|
14
|
+
getClientRuntimeConfig,
|
|
15
|
+
getCurrentSection,
|
|
16
|
+
IParams,
|
|
17
|
+
staticBuildMenu,
|
|
18
|
+
} from '../../../../components/util';
|
|
19
|
+
|
|
20
|
+
type PatternNavItem = { id: string; title: string; description: string; group: string };
|
|
21
|
+
|
|
22
|
+
interface PatternPageProps extends DocumentationProps {
|
|
23
|
+
id: string;
|
|
24
|
+
previousPattern: PatternNavItem | null;
|
|
25
|
+
nextPattern: PatternNavItem | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function getStaticPaths() {
|
|
29
|
+
return {
|
|
30
|
+
paths: fetchPatterns()?.map((p) => ({ params: { pattern: p.id } })) ?? [],
|
|
31
|
+
fallback: false,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const getStaticProps = async (context: { params: IParams }) => {
|
|
36
|
+
const { pattern: patternId } = context.params;
|
|
37
|
+
const patterns = fetchPatterns() ?? [];
|
|
38
|
+
const menu = staticBuildMenu();
|
|
39
|
+
const config = getClientRuntimeConfig();
|
|
40
|
+
const patternData = patterns.find((p) => p.id === patternId);
|
|
41
|
+
|
|
42
|
+
const sameGroupPatterns = patterns.filter((p) => p.group === patternData?.group);
|
|
43
|
+
const groupIndex = sameGroupPatterns.findIndex((p) => p.id === patternId);
|
|
44
|
+
const previousPattern = sameGroupPatterns[groupIndex - 1] ?? null;
|
|
45
|
+
const nextPattern = sameGroupPatterns[groupIndex + 1] ?? null;
|
|
46
|
+
|
|
47
|
+
const fallbackTitle = patternData?.title || startCase(patternId as string);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
props: {
|
|
51
|
+
id: patternId,
|
|
52
|
+
menu,
|
|
53
|
+
config,
|
|
54
|
+
current: getCurrentSection(menu, '/system') ?? [],
|
|
55
|
+
metadata: {
|
|
56
|
+
title: fallbackTitle,
|
|
57
|
+
description: patternData?.description || '',
|
|
58
|
+
metaTitle: `${fallbackTitle}${config?.app?.client ? ` | ${config.app.client} Design System` : ''}`,
|
|
59
|
+
metaDescription: patternData?.description || '',
|
|
60
|
+
},
|
|
61
|
+
previousPattern,
|
|
62
|
+
nextPattern,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const PatternPage = ({ menu, metadata, current, id, config, previousPattern, nextPattern }: PatternPageProps) => {
|
|
68
|
+
const [pattern, setPattern] = useState<PatternListObject | undefined>(undefined);
|
|
69
|
+
const [fetchError, setFetchError] = useState<string | undefined>(undefined);
|
|
70
|
+
const [iframeHeight, setIframeHeight] = useState('400px');
|
|
71
|
+
const iframeRef = React.useRef<HTMLIFrameElement>(null);
|
|
72
|
+
|
|
73
|
+
const appBasePath = process.env.HANDOFF_APP_BASE_PATH ?? '';
|
|
74
|
+
const normalizedBasePath = appBasePath ? `/${appBasePath.replace(/^\/+|\/+$/g, '')}` : '';
|
|
75
|
+
const patternRoute = (patternId: string) => `${normalizedBasePath}/system/pattern/${patternId}`;
|
|
76
|
+
|
|
77
|
+
const previousLink = previousPattern
|
|
78
|
+
? { href: patternRoute(previousPattern.id), title: previousPattern.title }
|
|
79
|
+
: null;
|
|
80
|
+
const nextLink = nextPattern
|
|
81
|
+
? { href: patternRoute(nextPattern.id), title: nextPattern.title }
|
|
82
|
+
: null;
|
|
83
|
+
const invalidComponents = pattern?.components?.filter((comp) => comp.resolved === false) ?? [];
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
setPattern(undefined);
|
|
88
|
+
setFetchError(undefined);
|
|
89
|
+
|
|
90
|
+
fetch(`${normalizedBasePath}/api/pattern/${id}.json`, { signal: controller.signal })
|
|
91
|
+
.then((res) => {
|
|
92
|
+
if (!res.ok) throw new Error(`Failed to load pattern: ${res.status} ${res.statusText}`);
|
|
93
|
+
return res.json();
|
|
94
|
+
})
|
|
95
|
+
.then((data) => setPattern(data as PatternListObject))
|
|
96
|
+
.catch((err) => {
|
|
97
|
+
if (err.name !== 'AbortError') {
|
|
98
|
+
setFetchError(err instanceof Error ? err.message : 'Failed to load pattern data.');
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return () => controller.abort();
|
|
103
|
+
}, [id, normalizedBasePath]);
|
|
104
|
+
|
|
105
|
+
const onIframeLoad = useCallback(() => {
|
|
106
|
+
if (iframeRef.current?.contentWindow?.document?.body) {
|
|
107
|
+
setIframeHeight(iframeRef.current.contentWindow.document.body.scrollHeight + 'px');
|
|
108
|
+
}
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
if (fetchError) return <p className="p-4 text-red-500">{fetchError}</p>;
|
|
112
|
+
if (!pattern) return <p>Loading...</p>;
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<Layout config={config} menu={menu} current={current} metadata={metadata}>
|
|
116
|
+
<div className="flex flex-col gap-3 pb-14">
|
|
117
|
+
<small className="text-sm font-medium text-sky-600 dark:text-gray-300">Patterns</small>
|
|
118
|
+
<HeadersType.H1>{metadata.title}</HeadersType.H1>
|
|
119
|
+
{metadata.description && (
|
|
120
|
+
<div className="prose max-w-[800px] text-xl font-light leading-relaxed text-gray-600 dark:text-gray-300">
|
|
121
|
+
<ReactMarkdown components={MarkdownComponents} rehypePlugins={[rehypeRaw]}>
|
|
122
|
+
{metadata.description}
|
|
123
|
+
</ReactMarkdown>
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div className="max-w-[1100px]">
|
|
129
|
+
{/* Pattern composition info */}
|
|
130
|
+
<div className="mb-4 flex items-center gap-2 text-sm text-gray-500">
|
|
131
|
+
<span>{pattern.components?.length ?? 0} component(s)</span>
|
|
132
|
+
{pattern.group && (
|
|
133
|
+
<>
|
|
134
|
+
<span className="text-gray-300">|</span>
|
|
135
|
+
<span>{pattern.group}</span>
|
|
136
|
+
</>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
{invalidComponents.length > 0 && (
|
|
141
|
+
<div className="mb-4 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200">
|
|
142
|
+
{invalidComponents.length} pattern component{invalidComponents.length > 1 ? 's could' : ' could'} not be resolved and {invalidComponents.length > 1 ? 'were' : 'was'} skipped.
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{/* Full-page pattern preview */}
|
|
147
|
+
<div className="rounded-lg border border-gray-200 dark:border-gray-900">
|
|
148
|
+
<div className="flex w-full items-center justify-between rounded-t-lg bg-gray-50 px-6 py-2 dark:bg-gray-800">
|
|
149
|
+
<p className="font-monospace text-[11px] text-accent-foreground">Pattern Preview</p>
|
|
150
|
+
</div>
|
|
151
|
+
<div className="dotted-bg w-full p-4">
|
|
152
|
+
{pattern.url ? (
|
|
153
|
+
<iframe
|
|
154
|
+
ref={iframeRef}
|
|
155
|
+
onLoad={onIframeLoad}
|
|
156
|
+
height={iframeHeight}
|
|
157
|
+
style={{
|
|
158
|
+
width: '100%',
|
|
159
|
+
height: iframeHeight,
|
|
160
|
+
display: 'block',
|
|
161
|
+
border: 'none',
|
|
162
|
+
}}
|
|
163
|
+
src={`${normalizedBasePath}/api/pattern/${pattern.url}`}
|
|
164
|
+
/>
|
|
165
|
+
) : (
|
|
166
|
+
<div className="flex items-center justify-center p-8 text-sm text-gray-500">
|
|
167
|
+
No preview available for this pattern.
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
{/* Component list */}
|
|
174
|
+
{pattern.components && pattern.components.length > 0 && (
|
|
175
|
+
<div className="mt-8">
|
|
176
|
+
<HeadersType.H3>Components in this pattern</HeadersType.H3>
|
|
177
|
+
<div className="mt-4 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-800">
|
|
178
|
+
<table className="w-full text-sm">
|
|
179
|
+
<thead className="border-b bg-gray-50 dark:bg-gray-800">
|
|
180
|
+
<tr>
|
|
181
|
+
<th className="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">Component</th>
|
|
182
|
+
<th className="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">Preview</th>
|
|
183
|
+
<th className="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">Status</th>
|
|
184
|
+
<th className="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">Custom Args</th>
|
|
185
|
+
</tr>
|
|
186
|
+
</thead>
|
|
187
|
+
<tbody>
|
|
188
|
+
{pattern.components.map((comp, i) => (
|
|
189
|
+
<tr key={`${comp.id}-${i}`} className="border-b last:border-b-0 dark:border-gray-800">
|
|
190
|
+
<td className="px-4 py-2 font-mono text-xs">{comp.id}</td>
|
|
191
|
+
<td className="px-4 py-2 text-xs text-gray-500">
|
|
192
|
+
{comp.preview || comp.resolvedPreview || 'default'}
|
|
193
|
+
</td>
|
|
194
|
+
<td className="px-4 py-2 text-xs">
|
|
195
|
+
{comp.resolved === false ? (
|
|
196
|
+
<span className="inline-flex w-fit rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-800 dark:bg-amber-900/60 dark:text-amber-200">
|
|
197
|
+
Unresolved
|
|
198
|
+
</span>
|
|
199
|
+
) : (
|
|
200
|
+
<span className="inline-flex w-fit rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-800 dark:bg-emerald-900/60 dark:text-emerald-200">
|
|
201
|
+
OK
|
|
202
|
+
</span>
|
|
203
|
+
)}
|
|
204
|
+
</td>
|
|
205
|
+
<td className="px-4 py-2 text-xs text-gray-500">
|
|
206
|
+
{comp.args ? JSON.stringify(comp.args) : '-'}
|
|
207
|
+
</td>
|
|
208
|
+
</tr>
|
|
209
|
+
))}
|
|
210
|
+
</tbody>
|
|
211
|
+
</table>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
|
|
216
|
+
<hr className="mt-8" />
|
|
217
|
+
<PrevNextNav previous={previousLink} next={nextLink} />
|
|
218
|
+
</div>
|
|
219
|
+
</Layout>
|
|
220
|
+
);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export default PatternPage;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useRouter } from 'next/router';
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
|
|
4
|
+
const PatternIndexRedirect = () => {
|
|
5
|
+
const router = useRouter();
|
|
6
|
+
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
router.replace('/system');
|
|
9
|
+
}, [router]);
|
|
10
|
+
|
|
11
|
+
return null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default PatternIndexRedirect;
|
|
@@ -1,14 +1,42 @@
|
|
|
1
1
|
# App Builder Module
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Builds and runs the Next.js documentation app from runtime config + generated preview output.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## End-to-End Flow
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
1. **Initialize app shell** (`build.ts`)
|
|
8
|
+
- Prepare `.handoff/<project>/app`, copy app sources, sync `public`, persist client config.
|
|
9
|
+
|
|
10
|
+
2. **Build component artifacts**
|
|
11
|
+
- Production: `buildComponents` + `buildPatterns`.
|
|
12
|
+
- Watch mode: `processComponents(..., { useCache: true })` to skip unchanged components.
|
|
13
|
+
|
|
14
|
+
3. **Build global bundles**
|
|
15
|
+
- Build main JS/CSS entry bundles when configured.
|
|
16
|
+
|
|
17
|
+
4. **Run/watch**
|
|
18
|
+
- Production: run `next build` and publish static output.
|
|
19
|
+
- Watch: run `next dev` + WebSocket + chokidar watchers.
|
|
20
|
+
|
|
21
|
+
5. **Incremental rebuild behavior** (`watchers.ts`)
|
|
22
|
+
- Source entry change -> targeted component segment rebuild.
|
|
23
|
+
- Main config/global entry change -> full component rebuild.
|
|
24
|
+
- Pattern config change -> diff-aware selective preview rebuild only for affected components.
|
|
25
|
+
- After component rebuilds -> run config-diff finalizers (e.g. pattern composition).
|
|
26
|
+
|
|
27
|
+
6. **Event coalescing**
|
|
28
|
+
- Watchers share a single scheduler lock; rapid updates from the same source coalesce.
|
|
29
|
+
|
|
30
|
+
## Key Files
|
|
31
|
+
|
|
32
|
+
| Path | Purpose |
|
|
8
33
|
|------|---------|
|
|
9
|
-
| `build.ts` | `buildApp()
|
|
10
|
-
| `
|
|
11
|
-
| `
|
|
12
|
-
| `
|
|
13
|
-
| `
|
|
14
|
-
| `
|
|
34
|
+
| `build.ts` | `buildApp()`, `watchApp()`, `devApp()` orchestration |
|
|
35
|
+
| `watchers.ts` | Watcher orchestration and rebuild routing |
|
|
36
|
+
| `watchers/component.ts` | Runtime component path mapping + segment resolution helpers |
|
|
37
|
+
| `watchers/utils.ts` | Coalescing scheduler + watcher state |
|
|
38
|
+
| `config-diff/index.ts` | Diff strategy exports + registry/finalizers |
|
|
39
|
+
| `config-diff/strategies/pattern.ts` | Pattern snapshot/diff/apply strategy |
|
|
40
|
+
| `client-config.ts` | Persist client runtime config + token API payload |
|
|
41
|
+
| `paths.ts` | App/public path helpers + public sync |
|
|
42
|
+
| `websocket.ts` | Dev reload WebSocket server |
|
package/src/app-builder/build.ts
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'fs-extra';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import Handoff from '..';
|
|
5
5
|
import { buildComponents } from '../pipeline/components';
|
|
6
|
+
import { buildPatterns } from '../pipeline/patterns';
|
|
6
7
|
import { buildMainCss } from '../transformers/preview/component/css';
|
|
7
8
|
import { buildMainJS } from '../transformers/preview/component/javascript';
|
|
8
9
|
import processComponents from '../transformers/preview/component/builder';
|
|
@@ -90,9 +91,10 @@ const buildApp = async (handoff: Handoff, skipComponents?: boolean): Promise<voi
|
|
|
90
91
|
// Perform cleanup
|
|
91
92
|
await cleanupAppDirectory(handoff);
|
|
92
93
|
|
|
93
|
-
// Build components
|
|
94
|
+
// Build components, then patterns (patterns depend on component output)
|
|
94
95
|
if (!skipComponents) {
|
|
95
96
|
await buildComponents(handoff);
|
|
97
|
+
await buildPatterns(handoff);
|
|
96
98
|
}
|
|
97
99
|
|
|
98
100
|
// Prepare app
|
|
@@ -143,6 +145,9 @@ export const watchApp = async (handoff: Handoff): Promise<void> => {
|
|
|
143
145
|
await buildMainJS(handoff);
|
|
144
146
|
await buildMainCss(handoff);
|
|
145
147
|
|
|
148
|
+
// Build patterns after components are ready
|
|
149
|
+
await buildPatterns(handoff);
|
|
150
|
+
|
|
146
151
|
const appPath = await initializeProjectApp(handoff);
|
|
147
152
|
|
|
148
153
|
// Persist client configuration
|
|
@@ -190,7 +195,8 @@ export const watchApp = async (handoff: Handoff): Promise<void> => {
|
|
|
190
195
|
};
|
|
191
196
|
|
|
192
197
|
const state: WatcherState = {
|
|
193
|
-
|
|
198
|
+
busy: false,
|
|
199
|
+
pendingHandlers: new Map(),
|
|
194
200
|
runtimeComponentsWatcher: null,
|
|
195
201
|
runtimeConfigurationWatcher: null,
|
|
196
202
|
componentDirectoriesWatcher: null,
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type { ConfigDiffStrategy, RebuildHandle } from './types';
|
|
2
|
+
export { type MapSnapshot, diffMapSnapshots, stableStringify } from './snapshot';
|
|
3
|
+
export { patternDiffStrategy } from './strategies/pattern';
|
|
4
|
+
export { getAllStrategies, getStrategy, runAllFinalizers } from './registry';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import Handoff from '../..';
|
|
2
|
+
import { patternDiffStrategy } from './strategies/pattern';
|
|
3
|
+
import { ConfigDiffStrategy } from './types';
|
|
4
|
+
|
|
5
|
+
const strategies = new Map<string, ConfigDiffStrategy>([
|
|
6
|
+
[patternDiffStrategy.kind, patternDiffStrategy],
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
export const getStrategy = (kind: string): ConfigDiffStrategy | undefined =>
|
|
10
|
+
strategies.get(kind);
|
|
11
|
+
|
|
12
|
+
export const getAllStrategies = (): ConfigDiffStrategy[] =>
|
|
13
|
+
Array.from(strategies.values());
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Runs every registered strategy finalizer.
|
|
17
|
+
* Called after component rebuilds so derived artifacts stay up-to-date.
|
|
18
|
+
*/
|
|
19
|
+
export const runAllFinalizers = async (handoff: Handoff): Promise<void> => {
|
|
20
|
+
for (const strategy of strategies.values()) {
|
|
21
|
+
await strategy.finalize(handoff);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** outerKey -> Map<innerKey, serializedValue> */
|
|
2
|
+
export type MapSnapshot = Map<string, Map<string, string>>;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Deterministic JSON serializer that sorts object keys at every nesting level.
|
|
6
|
+
* Prevents false-positive diffs when identical values are constructed with
|
|
7
|
+
* different key insertion order across reloads.
|
|
8
|
+
*/
|
|
9
|
+
export const stableStringify = (value: unknown): string =>
|
|
10
|
+
JSON.stringify(value, (_, v) =>
|
|
11
|
+
v && typeof v === 'object' && !Array.isArray(v)
|
|
12
|
+
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)))
|
|
13
|
+
: v
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compares two MapSnapshots and returns the outer keys where the inner maps
|
|
18
|
+
* differ (added, removed, or values changed).
|
|
19
|
+
*/
|
|
20
|
+
export const diffMapSnapshots = (
|
|
21
|
+
oldSnapshot: MapSnapshot,
|
|
22
|
+
newSnapshot: MapSnapshot
|
|
23
|
+
): string[] => {
|
|
24
|
+
const affected = new Set<string>();
|
|
25
|
+
|
|
26
|
+
for (const [key, oldEntries] of oldSnapshot) {
|
|
27
|
+
const newEntries = newSnapshot.get(key);
|
|
28
|
+
if (!newEntries) {
|
|
29
|
+
affected.add(key);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (oldEntries.size !== newEntries.size) {
|
|
33
|
+
affected.add(key);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
for (const [subKey, oldValue] of oldEntries) {
|
|
37
|
+
if (newEntries.get(subKey) !== oldValue) {
|
|
38
|
+
affected.add(key);
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const key of newSnapshot.keys()) {
|
|
45
|
+
if (!oldSnapshot.has(key)) {
|
|
46
|
+
affected.add(key);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return Array.from(affected);
|
|
51
|
+
};
|