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.
Files changed (143) hide show
  1. package/config/config.template.js +21 -14
  2. package/config/config.template.ts +54 -0
  3. package/config/docs/system.md +2 -0
  4. package/dist/app-builder/build.js +7 -2
  5. package/dist/app-builder/config-diff/index.d.ts +4 -0
  6. package/dist/app-builder/config-diff/index.js +12 -0
  7. package/dist/app-builder/config-diff/registry.d.ts +9 -0
  8. package/dist/app-builder/config-diff/registry.js +30 -0
  9. package/dist/app-builder/config-diff/snapshot.d.ts +13 -0
  10. package/dist/app-builder/config-diff/snapshot.js +43 -0
  11. package/dist/app-builder/config-diff/strategies/pattern.d.ts +2 -0
  12. package/dist/app-builder/config-diff/strategies/pattern.js +93 -0
  13. package/dist/app-builder/config-diff/types.d.ts +19 -0
  14. package/dist/app-builder/config-diff/types.js +2 -0
  15. package/dist/app-builder/watchers/component.d.ts +15 -0
  16. package/dist/app-builder/watchers/component.js +69 -0
  17. package/dist/app-builder/watchers/utils.d.ts +14 -0
  18. package/dist/app-builder/watchers/utils.js +44 -0
  19. package/dist/app-builder/watchers.d.ts +10 -19
  20. package/dist/app-builder/watchers.js +56 -102
  21. package/dist/cache/build-cache.js +14 -5
  22. package/dist/cli/create.js +67 -23
  23. package/dist/cli/eject.js +48 -7
  24. package/dist/cli/make.js +29 -4
  25. package/dist/cli/scaffold.js +60 -19
  26. package/dist/cli.js +1 -1
  27. package/dist/config/helpers.d.ts +7 -0
  28. package/dist/config/helpers.js +31 -0
  29. package/dist/config/index.d.ts +2 -1
  30. package/dist/config/index.js +5 -1
  31. package/dist/config/loader.d.ts +10 -1
  32. package/dist/config/loader.js +92 -28
  33. package/dist/config/normalizers/declaration.d.ts +11 -0
  34. package/dist/config/normalizers/declaration.js +119 -0
  35. package/dist/config/normalizers/pattern.d.ts +9 -0
  36. package/dist/config/normalizers/pattern.js +38 -0
  37. package/dist/config/runtime.d.ts +9 -3
  38. package/dist/config/runtime.js +254 -33
  39. package/dist/declarations/helpers.d.ts +6 -0
  40. package/dist/declarations/helpers.js +19 -0
  41. package/dist/declarations/index.d.ts +2 -0
  42. package/dist/declarations/index.js +9 -0
  43. package/dist/declarations/types.d.ts +60 -0
  44. package/dist/declarations/types.js +2 -0
  45. package/dist/index.d.ts +12 -1
  46. package/dist/index.js +34 -5
  47. package/dist/pipeline/index.d.ts +1 -0
  48. package/dist/pipeline/index.js +3 -1
  49. package/dist/pipeline/patterns.d.ts +6 -0
  50. package/dist/pipeline/patterns.js +21 -0
  51. package/dist/transformers/docgen/index.js +12 -2
  52. package/dist/transformers/plugins/csf-render.js +170 -34
  53. package/dist/transformers/plugins/ssr-render.js +18 -2
  54. package/dist/transformers/preview/component/api.js +16 -7
  55. package/dist/transformers/preview/component/builder.js +9 -3
  56. package/dist/transformers/preview/component/css.d.ts +8 -0
  57. package/dist/transformers/preview/component/html.js +14 -5
  58. package/dist/transformers/preview/component/previews.d.ts +9 -0
  59. package/dist/transformers/preview/component/previews.js +10 -0
  60. package/dist/transformers/preview/pattern/api.d.ts +7 -0
  61. package/dist/transformers/preview/pattern/api.js +61 -0
  62. package/dist/transformers/preview/pattern/builder.d.ts +9 -0
  63. package/dist/transformers/preview/pattern/builder.js +86 -0
  64. package/dist/transformers/preview/pattern/html.d.ts +19 -0
  65. package/dist/transformers/preview/pattern/html.js +94 -0
  66. package/dist/transformers/preview/types.d.ts +54 -0
  67. package/dist/transformers/utils/csf.d.ts +16 -0
  68. package/dist/transformers/utils/csf.js +30 -0
  69. package/dist/transformers/utils/index.d.ts +1 -0
  70. package/dist/transformers/utils/index.js +2 -0
  71. package/dist/transformers/utils/module.d.ts +5 -0
  72. package/dist/transformers/utils/module.js +26 -13
  73. package/dist/transformers/utils/usage.d.ts +23 -0
  74. package/dist/transformers/utils/usage.js +294 -0
  75. package/dist/types/config.d.ts +32 -7
  76. package/dist/types/preview.d.ts +2 -0
  77. package/docs/cli.md +3 -3
  78. package/package.json +3 -1
  79. package/src/app/components/Component/PageSliceResolver.tsx +6 -6
  80. package/src/app/components/Component/Preview.tsx +17 -12
  81. package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +16 -6
  82. package/src/app/components/Markdown/CodeHighlight.tsx +60 -11
  83. package/src/app/components/util/index.ts +71 -2
  84. package/src/app/pages/system/pattern/[pattern]/index.tsx +223 -0
  85. package/src/app/pages/system/pattern/index.tsx +14 -0
  86. package/src/app-builder/README.md +37 -9
  87. package/src/app-builder/build.ts +8 -2
  88. package/src/app-builder/config-diff/index.ts +4 -0
  89. package/src/app-builder/config-diff/registry.ts +23 -0
  90. package/src/app-builder/config-diff/snapshot.ts +51 -0
  91. package/src/app-builder/config-diff/strategies/pattern.ts +65 -0
  92. package/src/app-builder/config-diff/types.ts +21 -0
  93. package/src/app-builder/watchers/component.ts +72 -0
  94. package/src/app-builder/watchers/utils.ts +44 -0
  95. package/src/app-builder/watchers.ts +62 -80
  96. package/src/cache/build-cache.ts +14 -5
  97. package/src/cli/create.ts +86 -25
  98. package/src/cli/eject.ts +29 -7
  99. package/src/cli/make.ts +32 -5
  100. package/src/cli/scaffold.ts +68 -21
  101. package/src/cli.ts +1 -1
  102. package/src/config/README.md +10 -1
  103. package/src/config/helpers.ts +43 -0
  104. package/src/config/index.ts +4 -1
  105. package/src/config/loader.ts +110 -31
  106. package/src/config/normalizers/declaration.ts +176 -0
  107. package/src/config/normalizers/pattern.ts +63 -0
  108. package/src/config/runtime.ts +310 -35
  109. package/src/declarations/helpers.ts +36 -0
  110. package/src/declarations/index.ts +19 -0
  111. package/src/declarations/types.ts +70 -0
  112. package/src/index.ts +47 -6
  113. package/src/pipeline/index.ts +1 -0
  114. package/src/pipeline/patterns.ts +10 -0
  115. package/src/transformers/README.md +2 -2
  116. package/src/transformers/docgen/index.ts +13 -2
  117. package/src/transformers/plugins/csf-render.ts +216 -49
  118. package/src/transformers/plugins/ssr-render.ts +23 -7
  119. package/src/transformers/preview/component/api.ts +27 -7
  120. package/src/transformers/preview/component/builder.ts +21 -4
  121. package/src/transformers/preview/component/html.ts +18 -5
  122. package/src/transformers/preview/component/previews.ts +12 -0
  123. package/src/transformers/preview/pattern/api.ts +66 -0
  124. package/src/transformers/preview/pattern/builder.ts +94 -0
  125. package/src/transformers/preview/pattern/html.ts +107 -0
  126. package/src/transformers/preview/types.ts +58 -1
  127. package/src/transformers/utils/csf.ts +48 -0
  128. package/src/transformers/utils/index.ts +3 -0
  129. package/src/transformers/utils/module.ts +33 -18
  130. package/src/transformers/utils/usage.ts +319 -0
  131. package/src/types/README.md +1 -1
  132. package/src/types/config.ts +33 -7
  133. package/src/types/preview.ts +2 -0
  134. package/templates/default/components/button/Button.jsx.tpl +64 -0
  135. package/templates/default/components/button/button.react.handoff.js.tpl +31 -0
  136. package/templates/default/components/button/button.react.handoff.ts.tpl +31 -0
  137. package/templates/default/handoff.config.js.tpl +29 -16
  138. package/templates/default/handoff.config.ts.tpl +196 -0
  139. package/templates/default/handoff.config.with-examples.js.tpl +28 -17
  140. package/templates/default/handoff.config.with-examples.ts.tpl +195 -0
  141. package/templates/default/package.json.tpl +1 -3
  142. package/handoff.config.js +0 -90
  143. 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
- currentValues?: Record<string, string>;
51
- }> = ({ data, collapsible, type, title, dark, height, currentValues }) => {
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 = 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 [activeState, setActiveState] = useState<string>(states[0]);
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, currentValues, data]);
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
- {states.length > 2 && (
268
+ {selectableStates.length > 1 && (
223
269
  <Select
224
- defaultValue={activeState}
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
- {states
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
- Handles building, watching, and serving the Next.js documentation application.
3
+ Builds and runs the Next.js documentation app from runtime config + generated preview output.
4
4
 
5
- ## Files
5
+ ## End-to-End Flow
6
6
 
7
- | File | Purpose |
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()` — production build; `watchApp()` — dev server with watchers; `devApp()` — bare Next.js dev |
10
- | `websocket.ts` | `createWebSocketServer()` — WebSocket server for dev hot-reloading |
11
- | `paths.ts` | `getAppPath()`, `getWorkingPublicPath()`, `syncPublicFiles()` |
12
- | `client-config.ts` | `persistClientConfig()`, `generateTokensApi()` |
13
- | `watchers.ts` | File watchers for public dir, pages, global entries (SCSS/JS), runtime components, and config |
14
- | `index.ts` | Barrel re-exports |
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 |
@@ -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
- debounce: false,
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
+ };