@krapwoo/ds-viewer 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +182 -78
  2. package/dist/cli/agentsFile.js +31 -0
  3. package/dist/cli/config.js +26 -1
  4. package/dist/cli/dev.js +50 -5
  5. package/dist/cli/doctor.js +492 -0
  6. package/dist/cli/explain.js +151 -0
  7. package/dist/cli/githubAction.js +68 -0
  8. package/dist/cli/glob.js +8 -0
  9. package/dist/cli/init.js +152 -18
  10. package/dist/cli/main.js +115 -4
  11. package/dist/cli/pageIndex.js +27 -1
  12. package/dist/cli/props.js +170 -21
  13. package/dist/cli/staticPage.js +356 -0
  14. package/dist/cli/sync.js +28 -5
  15. package/dist/cli/workspace.js +49 -21
  16. package/dist/native/catalog/comparison.js +283 -0
  17. package/dist/native/catalog/tokens.js +119 -0
  18. package/dist/native/catalog/types.js +1 -0
  19. package/native/catalog/CatalogShell.tsx +6 -1
  20. package/native/catalog/CatalogSidebar.tsx +98 -5
  21. package/native/catalog/ComparisonGrid.tsx +9 -7
  22. package/native/catalog/SpacingScaleGallery.tsx +1 -1
  23. package/native/catalog/TokenRow.tsx +3 -2
  24. package/native/catalog/catalogNavigation.ts +1 -1
  25. package/native/catalog/comparison.ts +63 -5
  26. package/native/catalog/index.ts +8 -6
  27. package/native/catalog/logoLayout.ts +12 -0
  28. package/native/catalog/pageApi.ts +21 -3
  29. package/native/catalog/tokens.ts +5 -0
  30. package/native/catalog/types.ts +9 -6
  31. package/package.json +12 -6
  32. package/starter-kit/WHEN_TO_USE.md +124 -0
  33. package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +23 -0
  34. package/starter-kit/components/AnimatedChevron/AnimatedChevron.tsx +46 -0
  35. package/starter-kit/components/AnimatedChevron/index.ts +2 -0
  36. package/starter-kit/components/Avatar/Avatar.catalog.tsx +54 -0
  37. package/starter-kit/components/Avatar/Avatar.tsx +74 -0
  38. package/starter-kit/components/Avatar/index.ts +2 -0
  39. package/starter-kit/components/Badge/Badge.catalog.tsx +88 -0
  40. package/starter-kit/components/Badge/Badge.tsx +56 -0
  41. package/starter-kit/components/Badge/Badge.types.ts +18 -0
  42. package/starter-kit/components/Badge/index.ts +2 -0
  43. package/starter-kit/components/Banner/Banner.catalog.tsx +87 -0
  44. package/starter-kit/components/Banner/Banner.tsx +257 -0
  45. package/starter-kit/components/Banner/Banner.types.ts +38 -0
  46. package/starter-kit/components/Banner/index.ts +2 -0
  47. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +77 -0
  48. package/starter-kit/components/BottomSheet/BottomSheet.tsx +151 -0
  49. package/starter-kit/components/BottomSheet/index.ts +2 -0
  50. package/starter-kit/components/Button/Button.catalog.tsx +99 -0
  51. package/starter-kit/components/Button/Button.tsx +173 -0
  52. package/starter-kit/components/Button/Button.types.ts +52 -0
  53. package/starter-kit/components/Button/index.ts +2 -0
  54. package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +39 -0
  55. package/starter-kit/components/ButtonGroup/ButtonGroup.tsx +84 -0
  56. package/starter-kit/components/ButtonGroup/index.ts +2 -0
  57. package/starter-kit/components/Card/Card.catalog.tsx +68 -0
  58. package/starter-kit/components/Card/Card.tsx +133 -0
  59. package/starter-kit/components/Card/index.ts +2 -0
  60. package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +28 -0
  61. package/starter-kit/components/Checkbox/Checkbox.tsx +104 -0
  62. package/starter-kit/components/Checkbox/index.ts +2 -0
  63. package/starter-kit/components/Dialog/Dialog.catalog.tsx +41 -0
  64. package/starter-kit/components/Dialog/Dialog.tsx +82 -0
  65. package/starter-kit/components/Dialog/index.ts +2 -0
  66. package/starter-kit/components/Divider/Divider.catalog.tsx +35 -0
  67. package/starter-kit/components/Divider/Divider.tsx +20 -0
  68. package/starter-kit/components/Divider/index.ts +2 -0
  69. package/starter-kit/components/Dock/Dock.catalog.tsx +71 -0
  70. package/starter-kit/components/Dock/Dock.tsx +58 -0
  71. package/starter-kit/components/Dock/index.ts +2 -0
  72. package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +69 -0
  73. package/starter-kit/components/Dropdown/Dropdown.tsx +162 -0
  74. package/starter-kit/components/Dropdown/index.ts +2 -0
  75. package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +67 -0
  76. package/starter-kit/components/EmptyState/EmptyState.tsx +88 -0
  77. package/starter-kit/components/EmptyState/index.ts +2 -0
  78. package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +76 -0
  79. package/starter-kit/components/FieldContainer/FieldContainer.tsx +99 -0
  80. package/starter-kit/components/FieldContainer/FieldContainer.types.ts +27 -0
  81. package/starter-kit/components/FieldContainer/index.ts +2 -0
  82. package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +18 -0
  83. package/starter-kit/components/InputClearButton/InputClearButton.tsx +63 -0
  84. package/starter-kit/components/InputClearButton/index.ts +2 -0
  85. package/starter-kit/components/InputField/InputField.catalog.tsx +76 -0
  86. package/starter-kit/components/InputField/InputField.tsx +418 -0
  87. package/starter-kit/components/InputField/index.ts +2 -0
  88. package/starter-kit/components/List/List.catalog.tsx +30 -0
  89. package/starter-kit/components/List/List.tsx +36 -0
  90. package/starter-kit/components/List/index.ts +2 -0
  91. package/starter-kit/components/ListItem/ListItem.catalog.tsx +89 -0
  92. package/starter-kit/components/ListItem/ListItem.tsx +165 -0
  93. package/starter-kit/components/ListItem/index.ts +2 -0
  94. package/starter-kit/components/Loading/Loading.catalog.tsx +34 -0
  95. package/starter-kit/components/Loading/Loading.tsx +121 -0
  96. package/starter-kit/components/Loading/index.ts +2 -0
  97. package/starter-kit/components/Pill/Pill.catalog.tsx +79 -0
  98. package/starter-kit/components/Pill/Pill.tsx +181 -0
  99. package/starter-kit/components/Pill/Pill.types.ts +30 -0
  100. package/starter-kit/components/Pill/index.ts +2 -0
  101. package/starter-kit/components/PillRow/PillRow.catalog.tsx +91 -0
  102. package/starter-kit/components/PillRow/PillRow.tsx +140 -0
  103. package/starter-kit/components/PillRow/PillRow.types.ts +24 -0
  104. package/starter-kit/components/PillRow/index.ts +2 -0
  105. package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +22 -0
  106. package/starter-kit/components/ProgressDots/ProgressDots.tsx +42 -0
  107. package/starter-kit/components/ProgressDots/index.ts +2 -0
  108. package/starter-kit/components/Radio/Radio.catalog.tsx +38 -0
  109. package/starter-kit/components/Radio/Radio.tsx +114 -0
  110. package/starter-kit/components/Radio/index.ts +2 -0
  111. package/starter-kit/components/Radio/usePressScale.ts +33 -0
  112. package/starter-kit/components/SearchField/SearchField.catalog.tsx +28 -0
  113. package/starter-kit/components/SearchField/SearchField.tsx +98 -0
  114. package/starter-kit/components/SearchField/index.ts +2 -0
  115. package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +72 -0
  116. package/starter-kit/components/SectionHeader/SectionHeader.tsx +139 -0
  117. package/starter-kit/components/SectionHeader/index.ts +2 -0
  118. package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +31 -0
  119. package/starter-kit/components/SegmentedToggle/SegmentedToggle.tsx +163 -0
  120. package/starter-kit/components/SegmentedToggle/index.ts +2 -0
  121. package/starter-kit/components/SegmentedToggle/useSlideAnim.ts +29 -0
  122. package/starter-kit/components/Shimmer/Breathing.catalog.tsx +29 -0
  123. package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +89 -0
  124. package/starter-kit/components/Shimmer/Shimmer.tsx +158 -0
  125. package/starter-kit/components/Shimmer/Shimmer.types.ts +27 -0
  126. package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +39 -0
  127. package/starter-kit/components/Shimmer/index.ts +2 -0
  128. package/starter-kit/components/Surface/Surface.catalog.tsx +44 -0
  129. package/starter-kit/components/Surface/Surface.tsx +45 -0
  130. package/starter-kit/components/Surface/index.ts +2 -0
  131. package/starter-kit/components/Switch/Switch.catalog.tsx +33 -0
  132. package/starter-kit/components/Switch/Switch.tsx +129 -0
  133. package/starter-kit/components/Switch/index.ts +2 -0
  134. package/starter-kit/components/TextArea/TextArea.catalog.tsx +29 -0
  135. package/starter-kit/components/TextArea/TextArea.tsx +99 -0
  136. package/starter-kit/components/TextArea/index.ts +2 -0
  137. package/starter-kit/components/Toast/Toast.catalog.tsx +61 -0
  138. package/starter-kit/components/Toast/Toast.tsx +144 -0
  139. package/starter-kit/components/Toast/index.ts +2 -0
  140. package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +121 -0
  141. package/starter-kit/components/Tooltip/Tooltip.tsx +195 -0
  142. package/starter-kit/components/Tooltip/index.ts +2 -0
  143. package/starter-kit/components/TopNav/TopNav.catalog.tsx +71 -0
  144. package/starter-kit/components/TopNav/TopNav.tsx +77 -0
  145. package/starter-kit/components/TopNav/index.ts +2 -0
  146. package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +30 -0
  147. package/starter-kit/components/UnderlineTabs/UnderlineTabs.tsx +156 -0
  148. package/starter-kit/components/UnderlineTabs/index.ts +2 -0
  149. package/starter-kit/components/index.ts +36 -0
  150. package/starter-kit/components/statusColors.ts +27 -0
  151. package/starter-kit/icons/Icon.native.tsx +90 -0
  152. package/starter-kit/icons/index.ts +10 -0
  153. package/starter-kit/icons/paths.ts +623 -0
  154. package/starter-kit/icons/types.ts +69 -0
  155. package/starter-kit/pages/Colors.catalog.tsx +55 -0
  156. package/starter-kit/pages/Font.catalog.tsx +45 -0
  157. package/starter-kit/pages/Icons.catalog.tsx +51 -0
  158. package/starter-kit/pages/Manifest.catalog.tsx +24 -0
  159. package/starter-kit/pages/Motion.catalog.tsx +101 -0
  160. package/starter-kit/pages/Radius.catalog.tsx +35 -0
  161. package/starter-kit/pages/ReportIssue.catalog.tsx +148 -0
  162. package/starter-kit/pages/SavedTrips.catalog.tsx +247 -0
  163. package/starter-kit/pages/Shadow.catalog.tsx +34 -0
  164. package/starter-kit/pages/Spacing.catalog.tsx +12 -0
  165. package/starter-kit/pages/TripPlannerForm.catalog.tsx +48 -0
  166. package/starter-kit/pages/Typography.catalog.tsx +17 -0
  167. package/starter-kit/tokens/index.ts +50 -0
  168. package/starter-kit/tokens/motion.ts +90 -0
  169. package/starter-kit/tokens/palette.ts +111 -0
  170. package/starter-kit/tokens/scales.ts +106 -0
  171. package/starter-kit/tokens/semantic.ts +98 -0
  172. package/starter-kit/tokens/shadow.ts +36 -0
  173. package/starter-kit/tokens/typography.ts +72 -0
@@ -0,0 +1,492 @@
1
+ import * as ts from 'typescript';
2
+ import { readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { discoverAllPageFiles, resolvePageId } from "./pageIndex.js";
5
+ import { readHostTsconfigOptions } from "./props.js";
6
+ import { readStaticPages } from "./staticPage.js";
7
+ import { resolveGlob } from "./glob.js";
8
+ import { sync } from "./sync.js";
9
+ import { gridColumnLimit } from "../native/catalog/comparison.js";
10
+ function issue(fields) {
11
+ return fields;
12
+ }
13
+ function pageRef(page) {
14
+ return { page: resolvePageId(page, page.file), file: page.file };
15
+ }
16
+ /** Errata (controller, binding): a page discovered from a component folder (a `components` glob's
17
+ * folder, not a `pages` glob) with no explicit `component:` documents the component named by its
18
+ * own file (design §2; the viewer already applies this same default via `page.component ??
19
+ * page.id`, and the generated page index already defaults a missing `id` to the file stem). A
20
+ * page from a standalone `pages` glob is never touched — `componentFolders` only ever contains
21
+ * folders resolved from `config.components`, so a standalone page's own folder is never a member. */
22
+ export function applyComponentDefaults(pages, componentFolders) {
23
+ return pages.map((page) => page.component === undefined && componentFolders.has(path.dirname(page.file))
24
+ ? { ...page, component: resolvePageId(page, page.file) }
25
+ : page);
26
+ }
27
+ function checkParseErrors(pages) {
28
+ return pages
29
+ .filter((p) => Boolean(p.parseError))
30
+ .map((p) => issue({ id: 'page-parse-error', severity: 'error', file: p.file, message: `Failed to parse: ${p.parseError}`, fix: 'Fix the syntax error, then rerun doctor.' }));
31
+ }
32
+ function checkNotCheckable(pages) {
33
+ return pages
34
+ .filter((p) => !p.checkable && !p.parseError)
35
+ .map((p) => issue({
36
+ id: 'page-not-checkable', severity: 'warning', ...pageRef(p),
37
+ message: 'Page not statically checkable — some of its layout data is not a literal doctor can read.',
38
+ fix: 'Write rows/columns/items/propNotes as literal arrays/objects (or a same-file const), not a function call.',
39
+ }));
40
+ }
41
+ function checkComponentExportRemoved(pages, components) {
42
+ const names = new Set(components.map((c) => c.name));
43
+ return pages
44
+ .filter((p) => p.component && !names.has(p.component))
45
+ .map((p) => issue({
46
+ id: 'component-export-removed', severity: 'error', ...pageRef(p),
47
+ message: `Documents "${p.component}", which no longer exists as a component export.`,
48
+ fix: 'Delete this page, or update "component" to the export\'s new name.',
49
+ }));
50
+ }
51
+ /** True when a page documents at least one example by any mechanism design §4 recognizes — a
52
+ * grid, a `variants` item, a `states` item, or `render`. A page can be "fully covered" by
53
+ * `checkComponentExportRemoved`'s standard (it names a real component) and still have none of
54
+ * these — e.g. `init`'s own "Needs examples" draft, which sets only `component`/`group`/
55
+ * `description` — in which case the viewer shows the exact same "No examples documented." an
56
+ * undocumented component would. */
57
+ function pageHasExamples(page) {
58
+ return Boolean(page.comparison) || Boolean(page.variantsItems?.length) || Boolean(page.statesItems?.length) || page.hasRender === true;
59
+ }
60
+ function checkComponentNoExamples(pages, components) {
61
+ const byComponent = new Map();
62
+ for (const page of pages) {
63
+ if (page.component)
64
+ byComponent.set(page.component, [...(byComponent.get(page.component) ?? []), page]);
65
+ }
66
+ return components
67
+ .filter((c) => {
68
+ const componentPages = byComponent.get(c.name) ?? [];
69
+ // A not-statically-checkable page is given the benefit of the doubt here — it might have
70
+ // real examples this reader simply couldn't read (Global Constraint: never a partial read;
71
+ // its own `page-not-checkable` warning already covers it). Only "every page is checkable
72
+ // and genuinely empty" (or "no page at all") fires this rule.
73
+ return componentPages.length === 0 || componentPages.every((p) => p.checkable && !pageHasExamples(p));
74
+ })
75
+ .map((c) => {
76
+ const hasPage = (byComponent.get(c.name) ?? []).length > 0;
77
+ return issue({
78
+ id: 'component-no-examples', severity: 'warning', page: c.name, file: c.file,
79
+ message: hasPage
80
+ ? `"${c.name}" has a catalog page with no variants, states, comparison, or render — it will show "No examples documented."`
81
+ : `"${c.name}" has no catalog page — it will show "No examples documented."`,
82
+ fix: hasPage ? `Add variants/states/comparison, or render, to ${c.name}'s catalog page.` : `Add ${c.name}.catalog.tsx beside ${c.name}'s own file.`,
83
+ });
84
+ });
85
+ }
86
+ function checkDuplicatePageId(pages) {
87
+ const byId = new Map();
88
+ for (const page of pages) {
89
+ const id = resolvePageId(page, page.file);
90
+ byId.set(id, [...(byId.get(id) ?? []), page]);
91
+ }
92
+ const issues = [];
93
+ for (const [id, group] of byId) {
94
+ if (group.length < 2)
95
+ continue;
96
+ for (const page of group) {
97
+ const others = group.filter((p) => p !== page).map((p) => p.file).join(', ');
98
+ issues.push(issue({
99
+ id: 'duplicate-page-id', severity: 'error', page: id, file: page.file,
100
+ message: `Page id "${id}" is also used by ${others}.`,
101
+ fix: 'Give one of these pages its own "id".',
102
+ }));
103
+ }
104
+ }
105
+ return issues;
106
+ }
107
+ function checkGridAxisKeys(pages) {
108
+ const issues = [];
109
+ for (const page of pages) {
110
+ // Global Constraints: "never a partial read" — a not-checkable page gets only
111
+ // `page-not-checkable` (`checkNotCheckable` above); a *different* field on it happening to be
112
+ // literal (e.g. its grid, while its `propNotes` wasn't) is never grounds for a second opinion.
113
+ if (!page.checkable || !page.comparison)
114
+ continue;
115
+ for (const [axisName, axis] of [['row', page.comparison.rows], ['column', page.comparison.columns]]) {
116
+ const seen = new Set();
117
+ for (const item of axis.items) {
118
+ if (item.key === '') {
119
+ issues.push(issue({ id: 'grid-axis-key-invalid', severity: 'error', ...pageRef(page), message: `A ${axisName} has an empty key.`, fix: `Give every ${axisName} a non-empty, unique key.` }));
120
+ }
121
+ else if (seen.has(item.key)) {
122
+ issues.push(issue({ id: 'grid-axis-key-invalid', severity: 'error', ...pageRef(page), message: `Duplicate ${axisName} key "${item.key}".`, fix: `Give each ${axisName} its own key.` }));
123
+ }
124
+ seen.add(item.key);
125
+ }
126
+ }
127
+ }
128
+ return issues;
129
+ }
130
+ function checkGridColumnLimit(pages) {
131
+ const issues = [];
132
+ for (const page of pages) {
133
+ if (!page.checkable || !page.comparison)
134
+ continue; // same "never a partial read" reasoning as checkGridAxisKeys above.
135
+ const size = (page.comparison.size ?? page.specimenSize ?? 'regular');
136
+ const limit = gridColumnLimit(size);
137
+ const count = page.comparison.columns.items.length;
138
+ if (count > limit) {
139
+ issues.push(issue({
140
+ id: 'grid-column-limit', severity: 'error', ...pageRef(page),
141
+ message: `Grid has ${count} columns; at most ${limit} ${size} columns fit a 1280px laptop.`,
142
+ fix: 'Swap the axes, use two lists, or pick a smaller specimenSize.',
143
+ }));
144
+ }
145
+ }
146
+ return issues;
147
+ }
148
+ /** Everything that needs a page's own matching `ComponentRecord` to check: `propNotes`, bound
149
+ * axes, tagged list items, coverage, grouped-state matching, and guidance/a11y presence. One
150
+ * function because every one of these checks shares the same per-page `component`/`propsByName`
151
+ * lookup — splitting it further would only duplicate that lookup, not clarify anything. */
152
+ function checkComponentPages(pages, components) {
153
+ const componentsByName = new Map(components.map((c) => [c.name, c]));
154
+ const issues = [];
155
+ for (const page of pages) {
156
+ if (!page.component)
157
+ continue;
158
+ if (!page.checkable)
159
+ continue; // never a partial read — see checkGridAxisKeys's own note.
160
+ const component = componentsByName.get(page.component);
161
+ if (!component)
162
+ continue; // already reported by checkComponentExportRemoved
163
+ const ref = pageRef(page);
164
+ const propsByName = new Map(component.props.map((p) => [p.name, p]));
165
+ for (const key of page.propNoteKeys ?? []) {
166
+ if (!propsByName.has(key)) {
167
+ issues.push(issue({
168
+ id: 'propnotes-prop-removed', severity: 'error', ...ref,
169
+ message: `propNotes names "${key}", which is no longer a prop of ${component.name}.`,
170
+ fix: `Delete the "${key}" entry from propNotes, or rename it to the prop's new name.`,
171
+ }));
172
+ }
173
+ }
174
+ if (page.comparison) {
175
+ for (const [axisName, axis] of [['row', page.comparison.rows], ['column', page.comparison.columns]]) {
176
+ if (axis.prop === undefined)
177
+ continue;
178
+ const prop = propsByName.get(axis.prop);
179
+ if (!prop) {
180
+ issues.push(issue({
181
+ id: 'bound-axis-prop-removed', severity: 'error', ...ref,
182
+ message: `The ${axisName} axis is bound to "${axis.prop}", which is no longer a prop of ${component.name}.`,
183
+ fix: 'Update "prop" to the prop\'s new name, or unbind this axis.',
184
+ }));
185
+ continue;
186
+ }
187
+ if (!prop.options) {
188
+ issues.push(issue({
189
+ id: 'bound-axis-not-union', severity: 'error', ...ref,
190
+ message: `The ${axisName} axis is bound to "${axis.prop}", which is not a string-literal-union prop.`,
191
+ fix: 'Only a prop with a fixed set of string options can be bound; unbind this axis.',
192
+ }));
193
+ continue;
194
+ }
195
+ for (const item of axis.items) {
196
+ if (!prop.options.includes(item.key)) {
197
+ issues.push(issue({
198
+ id: 'bound-option-removed', severity: 'error', ...ref,
199
+ message: `The ${axisName} "${item.key}" is bound to "${axis.prop}", which no longer has that option.`,
200
+ fix: `Delete this ${axisName}, or update its key to a current option of "${axis.prop}".`,
201
+ }));
202
+ }
203
+ }
204
+ }
205
+ }
206
+ const listItems = [...(page.variantsItems ?? []), ...(page.statesItems ?? [])];
207
+ for (const item of listItems) {
208
+ for (const [propName, value] of Object.entries(item.props ?? {})) {
209
+ if (typeof value !== 'string')
210
+ continue;
211
+ const prop = propsByName.get(propName);
212
+ if (!prop) {
213
+ // A name missing from `props` is only real drift when the component has nothing
214
+ // inherited to explain it — `component.props` deliberately omits a node_modules-declared
215
+ // prop (cli/props.ts:200), so e.g. `autoCapitalize` on a `TextInput`-extending component
216
+ // is expected to be absent here, not a removed prop (design: `inheritedFrom` summarises
217
+ // those instead of listing them).
218
+ if (component.inheritedFrom.length > 0)
219
+ continue;
220
+ // The tagged prop *name* itself no longer exists — arguably the same drift as
221
+ // `bound-axis-prop-removed`, but a tagged list item (unlike a bound axis) has no
222
+ // "prop" field of its own to report that id against, so it's reported here instead.
223
+ // Previously silently skipped (`!prop?.options` was true for both "prop missing" and
224
+ // "prop exists but isn't a union"), masking exactly the drift this rule exists to catch.
225
+ issues.push(issue({
226
+ id: 'bound-option-removed', severity: 'error', ...ref,
227
+ message: `"${item.name}" is tagged props.${propName} = "${value}", but "${propName}" is no longer a prop of ${component.name}.`,
228
+ fix: 'Delete the tag, or update it to a current prop name.',
229
+ }));
230
+ continue;
231
+ }
232
+ if (!prop.options || prop.options.includes(value))
233
+ continue;
234
+ issues.push(issue({
235
+ id: 'bound-option-removed', severity: 'error', ...ref,
236
+ message: `"${item.name}" is tagged props.${propName} = "${value}", which is no longer an option of "${propName}".`,
237
+ fix: 'Update the tagged value, or delete this item if the option no longer exists.',
238
+ }));
239
+ }
240
+ }
241
+ for (const prop of component.props) {
242
+ if (!prop.options)
243
+ continue;
244
+ const covered = new Set();
245
+ if (page.comparison) {
246
+ for (const axis of [page.comparison.rows, page.comparison.columns]) {
247
+ if (axis.prop === prop.name)
248
+ for (const item of axis.items)
249
+ covered.add(item.key);
250
+ }
251
+ }
252
+ for (const item of listItems) {
253
+ const value = item.props?.[prop.name];
254
+ if (typeof value === 'string')
255
+ covered.add(value);
256
+ }
257
+ for (const option of prop.options.filter((o) => !covered.has(o))) {
258
+ issues.push(issue({
259
+ id: 'option-not-covered', severity: 'warning', ...ref,
260
+ message: `"${prop.name}" option "${option}" has no bound or tagged example.`,
261
+ fix: `Bind a grid axis to "${prop.name}", or add { props: { ${prop.name}: '${option}' } } to a variants/states item.`,
262
+ }));
263
+ }
264
+ }
265
+ const variantKeys = new Set((page.variantsItems ?? []).map((i) => i.key));
266
+ for (const item of page.statesItems ?? []) {
267
+ if (item.group !== undefined && !variantKeys.has(item.group)) {
268
+ issues.push(issue({
269
+ id: 'group-unmatched', severity: 'warning', ...ref,
270
+ message: `"${item.name}" names group "${item.group}", which matches no variant key; it will show under "Other configurations".`,
271
+ fix: 'Fix the group value, or remove it if this item isn\'t tied to a specific variant.',
272
+ }));
273
+ }
274
+ }
275
+ // Design §4 names this one rule, "missing guidance or accessibility notes" — one id, and (per
276
+ // the controller) both halves: a missing `whenToUse` and a missing `a11y` each get their own
277
+ // message under `missing-guidance-or-a11y`, since the viewer renders a visible placeholder
278
+ // ("No usage guidance documented."/"No accessibility notes documented.") for either gap, not
279
+ // just the second. Never invented content — both messages only ever point the author at
280
+ // writing their own, real sentence.
281
+ if (!page.tokenGallery && page.hasWhenToUse === false) {
282
+ issues.push(issue({
283
+ id: 'missing-guidance-or-a11y', severity: 'warning', ...ref,
284
+ message: 'No "whenToUse" guidance documented.',
285
+ fix: 'Add a one-sentence whenToUse note disambiguating this component from its closest look-alike(s), or omit it only when there genuinely is none.',
286
+ }));
287
+ }
288
+ if (!page.tokenGallery && page.hasA11y === false && !page.hidesAccessibility) {
289
+ issues.push(issue({
290
+ id: 'missing-guidance-or-a11y', severity: 'warning', ...ref,
291
+ message: 'No "a11y" notes documented.',
292
+ fix: 'Add a one-sentence a11y note, grounded in the component\'s real behavior — even "no explicit handling beyond defaults" is better than leaving it blank.',
293
+ }));
294
+ }
295
+ }
296
+ return issues;
297
+ }
298
+ /** Every page-scoped issue, from every rule above. */
299
+ export function collectIssues(pages, components) {
300
+ return [
301
+ ...checkParseErrors(pages),
302
+ ...checkNotCheckable(pages),
303
+ ...checkComponentExportRemoved(pages, components),
304
+ ...checkComponentNoExamples(pages, components),
305
+ ...checkDuplicatePageId(pages),
306
+ ...checkGridAxisKeys(pages),
307
+ ...checkGridColumnLimit(pages),
308
+ ...checkComponentPages(pages, components),
309
+ ];
310
+ }
311
+ /** Issues that aren't tied to one page: `sync`'s own "Could not read" warnings, and a configured
312
+ * project with no token modules at all. */
313
+ export function collectGlobalIssues(input) {
314
+ const issues = input.syncWarnings.map((warning) => issue({ id: 'props-unreadable', severity: 'warning', message: warning, fix: 'Fix the file so sync can read it, or exclude it from the components/tokens globs.' }));
315
+ if (input.tokenFileCount === 0) {
316
+ issues.push(issue({ id: 'no-token-modules', severity: 'warning', message: 'No token modules are configured.', fix: 'Add a tokens glob to ds-viewer.config.ts.' }));
317
+ }
318
+ return issues;
319
+ }
320
+ /** Design §4's other half of "fails to parse or typecheck" — the half `cli/staticPage.ts` (Task 3)
321
+ * deliberately never attempts, since it has no type checker and no module resolution at all.
322
+ * This builds exactly one `ts.Program` over every page file, using the identical compiler-host
323
+ * construction `cli/props.ts`'s `createComponentReader` already uses for component files (same
324
+ * `compilerOptions` shape, same `fallbackPaths` mechanism for a page outside the project root —
325
+ * `runDoctor`, Task 5, computes `fallbackPaths` the same way `cli/sync.ts` already does for
326
+ * component files), and reports every syntactic *and* semantic diagnostic as `page-parse-error`,
327
+ * with a real `line` (unlike every other rule in this file — see this task's own resolved-scope
328
+ * note above). Errata (controller, binding): a page that already has a `parseError` from
329
+ * `cli/staticPage.ts`'s own syntactic reader is skipped here — its syntax error was already
330
+ * reported once by `checkParseErrors`, and this program would otherwise report it again (its own
331
+ * syntactic diagnostics are a superset of what `ts.transpileModule` already found). A spike
332
+ * against the real kit (`spikes/page-typecheck/` in this plan's spike folder) found exactly one
333
+ * real, previously-uncaught diagnostic, fixed in Task 2. */
334
+ export function checkPageTypeErrors(pages, options = {}) {
335
+ const checkablePages = pages.filter((p) => !p.parseError);
336
+ const pageFiles = checkablePages.map((p) => p.file);
337
+ if (pageFiles.length === 0)
338
+ return [];
339
+ const byFile = new Map(checkablePages.map((p) => [p.file, p]));
340
+ // Important finding 2 (Fable's review): the host project's own tsconfig decides every option —
341
+ // its ambient declarations (expo-env.d.ts's `*.png`/`*.svg` modules, a project's own
342
+ // declarations.d.ts) and its `strict`/`lib`/etc. settings — not a fixed set copied from
343
+ // `createComponentReader` (where a mismatch only degrades prop reading silently; here it would
344
+ // produce CI-failing errors the host's own `tsc` would never raise). Only `noEmit`,
345
+ // `skipLibCheck`, and the merged `paths` are forced on top.
346
+ const host = readHostTsconfigOptions(pageFiles[0]);
347
+ const compilerOptions = {
348
+ ...host.options,
349
+ noEmit: true,
350
+ skipLibCheck: true,
351
+ paths: { ...options.fallbackPaths, ...host.paths },
352
+ };
353
+ const program = ts.createProgram([...pageFiles, ...host.declarationFiles], compilerOptions);
354
+ const issues = [];
355
+ for (const file of pageFiles) {
356
+ const sourceFile = program.getSourceFile(file);
357
+ if (!sourceFile)
358
+ continue;
359
+ const diagnostics = [...program.getSyntacticDiagnostics(sourceFile), ...program.getSemanticDiagnostics(sourceFile)];
360
+ const page = byFile.get(file);
361
+ const ref = page ? pageRef(page) : { file };
362
+ for (const d of diagnostics) {
363
+ const line = d.start !== undefined ? sourceFile.getLineAndCharacterOfPosition(d.start).line + 1 : undefined;
364
+ issues.push(issue({
365
+ id: 'page-parse-error', severity: 'error', ...ref, line,
366
+ message: `Failed to typecheck: ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`,
367
+ fix: 'Fix the error, then rerun doctor.',
368
+ }));
369
+ }
370
+ }
371
+ return issues;
372
+ }
373
+ /** "With examples" means what `checkComponentNoExamples` (Task 4) checks the *absence* of: at
374
+ * least one checkable page with a real example, not merely "has a page" (an `init`-drafted
375
+ * "Needs examples" page has one, with none). */
376
+ function countWithExamples(pages, components) {
377
+ const byComponent = new Map();
378
+ for (const page of pages) {
379
+ if (page.component)
380
+ byComponent.set(page.component, [...(byComponent.get(page.component) ?? []), page]);
381
+ }
382
+ return components.filter((c) => (byComponent.get(c.name) ?? []).some((p) => p.checkable && pageHasExamples(p))).length;
383
+ }
384
+ function countUnboundExamples(pages) {
385
+ let count = 0;
386
+ for (const page of pages) {
387
+ if (page.comparison) {
388
+ if (page.comparison.rows.prop === undefined)
389
+ count += page.comparison.rows.items.length;
390
+ if (page.comparison.columns.prop === undefined)
391
+ count += page.comparison.columns.items.length;
392
+ }
393
+ for (const item of [...(page.variantsItems ?? []), ...(page.statesItems ?? [])]) {
394
+ if (!item.props || Object.keys(item.props).length === 0)
395
+ count += 1;
396
+ }
397
+ }
398
+ return count;
399
+ }
400
+ /** Runs `sync`, reads every page `cli/staticPage.ts` can find (`discoverAllPageFiles` —
401
+ * deliberately wider than the viewer's own page list, so a renamed/removed component's orphaned
402
+ * page is still seen), applies the component-folder default (Errata, `applyComponentDefaults`
403
+ * above), and applies every rule from Task 4. `config.doctor.strict` promotes every warning to an
404
+ * error for both the summary counts and the exit code (design §2 Config: "`strict: true` promotes
405
+ * `doctor` warnings to errors"). */
406
+ export function runDoctor(config) {
407
+ const syncResult = sync(config);
408
+ const components = JSON.parse(readFileSync(path.join(syncResult.generatedDir, 'components.json'), 'utf8'));
409
+ // Same exclusion `sync` already applies to `componentEntryFiles` (cli/sync.ts:29-30) — without
410
+ // it, an excluded folder's own `*.catalog.tsx` is still discovered below (`discoverAllPageFiles`)
411
+ // even though its component never makes it into `components.json`, firing a false
412
+ // `component-export-removed`.
413
+ const excluded = new Set((config.exclude ?? []).flatMap((pattern) => resolveGlob(config.projectRoot, pattern)));
414
+ const componentFolders = [
415
+ ...new Set(config.components
416
+ .flatMap((pattern) => resolveGlob(config.projectRoot, pattern))
417
+ .filter((file) => !excluded.has(file))
418
+ .map((file) => path.dirname(file))),
419
+ ];
420
+ const tokenFileCount = config.tokens.flatMap((pattern) => resolveGlob(config.projectRoot, pattern)).length;
421
+ const pageFiles = discoverAllPageFiles({ componentFolders, standalonePageGlobs: config.pages ?? [], projectRoot: config.projectRoot });
422
+ const pages = applyComponentDefaults(readStaticPages(pageFiles), new Set(componentFolders));
423
+ // Same "a page outside the project root needs a fallback paths entry" rule `cli/sync.ts` already
424
+ // applies to component entry files (e.g. `kit-host`'s own component pages, which physically live
425
+ // in the sibling `../starter-kit/`) — mirrored here for page files, since `checkPageTypeErrors`
426
+ // below needs it for the identical reason `createComponentReader` does. `@krapwoo/ds-viewer`
427
+ // itself resolves from `../starter-kit` pages through Node's own self-name resolution (the repo
428
+ // root `package.json`'s `name` + `exports`), not this fallback — never a `starter-kit/package.json`.
429
+ const hasPageOutsideProjectRoot = pageFiles.some((file) => !file.startsWith(`${config.projectRoot}${path.sep}`));
430
+ const fallbackPaths = hasPageOutsideProjectRoot
431
+ ? { '*': [path.join(config.projectRoot, 'node_modules', '@types', '*'), path.join(config.projectRoot, 'node_modules', '*')] }
432
+ : undefined;
433
+ let issues = [
434
+ ...collectIssues(pages, components),
435
+ ...checkPageTypeErrors(pages, { fallbackPaths }),
436
+ ...collectGlobalIssues({ syncWarnings: syncResult.warnings, tokenFileCount }),
437
+ ];
438
+ // Design's own `--json` example gives a project-relative `file`; `StaticPage.file` is absolute
439
+ // (`discoverAllPageFiles`) and `ComponentRecord.file` is cwd-relative (`cli/props.ts`) — cwd
440
+ // equals `config.projectRoot` for every real CLI invocation, so resolving against one and
441
+ // re-relativizing against the other normalizes both to the same, documented shape.
442
+ issues = issues.map((i) => (i.file ? { ...i, file: path.relative(config.projectRoot, path.resolve(config.projectRoot, i.file)) } : i));
443
+ if (config.doctor?.strict)
444
+ issues = issues.map((i) => (i.severity === 'warning' ? { ...i, severity: 'error' } : i));
445
+ const summary = {
446
+ errors: issues.filter((i) => i.severity === 'error').length,
447
+ warnings: issues.filter((i) => i.severity === 'warning').length,
448
+ components: components.length,
449
+ withExamples: countWithExamples(pages, components),
450
+ unboundExamples: countUnboundExamples(pages),
451
+ };
452
+ return { version: 1, summary, update: null, issues, exitCode: summary.errors > 0 ? 1 : 0 };
453
+ }
454
+ export function toDoctorJson(result) {
455
+ const { version, summary, update, issues } = result;
456
+ return { version, summary, update, issues };
457
+ }
458
+ /** Human report (design §4 "Output"): grouped by page, each issue with its one-line fix; issues
459
+ * with no `page` (`props-unreadable`, `no-token-modules`) are listed under "General". Ends with
460
+ * the same counts `--json`'s `summary` carries. */
461
+ export function formatHuman(result) {
462
+ const byPage = new Map();
463
+ const general = [];
464
+ for (const issue of result.issues) {
465
+ if (issue.page)
466
+ byPage.set(issue.page, [...(byPage.get(issue.page) ?? []), issue]);
467
+ else
468
+ general.push(issue);
469
+ }
470
+ const lines = [];
471
+ const renderIssue = (issue) => {
472
+ const location = issue.file ? ` (${issue.file}${issue.line ? `:${issue.line}` : ''})` : '';
473
+ lines.push(` [${issue.severity}] ${issue.id}: ${issue.message}${location}`);
474
+ lines.push(` Fix: ${issue.fix}`);
475
+ };
476
+ for (const [page, issues] of [...byPage].sort(([a], [b]) => a.localeCompare(b))) {
477
+ lines.push(page);
478
+ for (const issue of issues)
479
+ renderIssue(issue);
480
+ lines.push('');
481
+ }
482
+ if (general.length > 0) {
483
+ lines.push('General');
484
+ for (const issue of general)
485
+ renderIssue(issue);
486
+ lines.push('');
487
+ }
488
+ const { errors, warnings, components, withExamples, unboundExamples } = result.summary;
489
+ lines.push(`${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'} — ` +
490
+ `${components} components (${withExamples} with examples), ${unboundExamples} unbound examples.`);
491
+ return lines.join('\n');
492
+ }
@@ -0,0 +1,151 @@
1
+ import path from 'node:path';
2
+ import { readStaticPages } from "./staticPage.js";
3
+ import { discoverAllPageFiles, resolvePageId } from "./pageIndex.js";
4
+ import { resolveGlob } from "./glob.js";
5
+ import { axisItems, choosePlacement, gridColumnLimit, listGeometry, presentationBlocks, MATRIX_LAYOUT, } from "../native/catalog/comparison.js";
6
+ import { CATALOG_LAYOUT } from "../native/catalog/tokens.js";
7
+ function placeholderItem(item) {
8
+ return { key: item.key, name: item.name, node: null, group: item.group };
9
+ }
10
+ /** A throwaway `SectionDef` built from a `StaticPage` (Task 3) — every specimen's `node` is `null`
11
+ * (design §4: specimen nodes are never evaluated), since none of the layout functions below ever
12
+ * look at it; only the counts/keys/sizes they do look at are carried over. Every field Task 3
13
+ * reads is carried through here, including `previewWidths`/`fullWidthLabel`/`hide`/`itemsFill` —
14
+ * an earlier draft dropped these four, so `explain` reported different geometry than the real
15
+ * viewer for any page that set one of them (25 kit pages set `itemsFill`; every `kit-host`
16
+ * Viewer page sets `hide`). */
17
+ function toSectionDef(page) {
18
+ const comparison = page.comparison && {
19
+ rowLabel: page.comparison.rowLabel ?? '',
20
+ columnLabel: page.comparison.columnLabel ?? '',
21
+ rows: page.comparison.rows.prop !== undefined ? { prop: page.comparison.rows.prop, items: page.comparison.rows.items } : page.comparison.rows.items,
22
+ columns: page.comparison.columns.prop !== undefined ? { prop: page.comparison.columns.prop, items: page.comparison.columns.items } : page.comparison.columns.items,
23
+ cells: [],
24
+ size: page.comparison.size,
25
+ };
26
+ return {
27
+ id: resolvePageId(page, page.file),
28
+ description: '',
29
+ path: page.file,
30
+ tokenGallery: page.tokenGallery,
31
+ fullWidthLabel: page.fullWidthLabel,
32
+ specimenSize: page.specimenSize,
33
+ previewWidths: page.previewWidths,
34
+ variants: page.variantsItems ? { itemsFill: page.variantsItemsFill, items: page.variantsItems.map(placeholderItem) } : undefined,
35
+ states: page.statesItems ? { itemsFill: page.statesItemsFill, items: page.statesItems.map(placeholderItem) } : undefined,
36
+ comparison,
37
+ render: page.hasRender ? () => null : undefined,
38
+ hide: (page.hideVariants || page.hideStates) ? { variants: page.hideVariants, states: page.hideStates } : undefined,
39
+ };
40
+ }
41
+ function explainBlock(block) {
42
+ if (block.kind === 'grid') {
43
+ const rows = axisItems(block.comparison.rows).length;
44
+ const columns = axisItems(block.comparison.columns).length;
45
+ const limit = gridColumnLimit(block.size);
46
+ return {
47
+ kind: 'grid', title: block.title,
48
+ reason: `${rows} rows × ${columns} columns (${block.size}); ${columns} of ${limit} max ${block.size} columns used — ` +
49
+ `${columns <= limit ? 'fits' : 'does NOT fit'} a 1280px laptop.`,
50
+ };
51
+ }
52
+ if (block.kind === 'list') {
53
+ const geometry = listGeometry(block.items.length, MATRIX_LAYOUT.laptopContentWidth, block.size);
54
+ return {
55
+ kind: 'list', title: block.title,
56
+ reason: `${block.items.length} items (${block.size}) → ${geometry.columns} columns × ${geometry.rows} row${geometry.rows === 1 ? '' : 's'}` +
57
+ (geometry.fillers > 0 ? ` (${geometry.fillers} filler${geometry.fillers === 1 ? '' : 's'})` : '') + '.',
58
+ };
59
+ }
60
+ if (block.kind === 'grouped') {
61
+ return { kind: 'grouped', title: block.title, reason: `${block.groups.length} variant row(s), each with its own configurations.` };
62
+ }
63
+ if (block.kind === 'preview') {
64
+ const widths = block.widths === 'full' ? 'full width' : `${block.widths.join('/')}px`;
65
+ return { kind: 'preview', title: block.title, reason: `render() content at ${widths}.` };
66
+ }
67
+ return { kind: 'empty', title: block.title, reason: block.message };
68
+ }
69
+ /** Mirrors `native/catalog/SectionBlock.tsx`'s own `blockWidth` helper: a list block's width is its
70
+ * own natural, content-hugging width (`listGeometry`'s `containerWidth`) — the real viewer only
71
+ * ever moves a *second* block beside a *first* one when that first block doesn't already fill the
72
+ * whole row, so `choosePlacement` only reads `first.width` (never `second.width`) — but it's
73
+ * computed uniformly here for either role, since setting it on `second` is simply unused, not wrong. */
74
+ function toPlacementBlock(block, height, available) {
75
+ if (block.kind === 'list') {
76
+ return { kind: 'list', height, itemCount: block.items.length, size: block.size, width: listGeometry(block.items.length, available, block.size).containerWidth };
77
+ }
78
+ return { kind: block.kind, height };
79
+ }
80
+ /** Finds `pageId` among every page `cli/doctor.ts`'s own `discoverAllPageFiles` would find, builds
81
+ * its throwaway `SectionDef`, and asks the viewer's own pure layout functions for each block's
82
+ * decision — undefined when no page resolves to that id. */
83
+ export function explainPage(config, pageId, options = {}) {
84
+ // Same exclusion `cli/doctor.ts`'s `runDoctor` applies (mirroring `sync`) — an excluded folder's
85
+ // page should never resolve here either.
86
+ const excluded = new Set((config.exclude ?? []).flatMap((pattern) => resolveGlob(config.projectRoot, pattern)));
87
+ const componentFolders = [
88
+ ...new Set(config.components
89
+ .flatMap((pattern) => resolveGlob(config.projectRoot, pattern))
90
+ .filter((file) => !excluded.has(file))
91
+ .map((file) => path.dirname(file))),
92
+ ];
93
+ const pageFiles = discoverAllPageFiles({ componentFolders, standalonePageGlobs: config.pages ?? [], projectRoot: config.projectRoot });
94
+ const page = readStaticPages(pageFiles).find((p) => resolvePageId(p, p.file) === pageId);
95
+ if (!page)
96
+ return undefined;
97
+ // Important finding 4 (Fable's review): a page whose layout data isn't a literal (or that has a
98
+ // syntax error) must never be explained from partial/empty data — design §4 requires `explain`
99
+ // to never drift from what the viewer actually renders.
100
+ if (!page.checkable)
101
+ return { pageId, notCheckable: true, blocks: [] };
102
+ const def = toSectionDef(page);
103
+ const blocks = presentationBlocks(def, { defaultPreviewWidths: [402] });
104
+ const explained = blocks.map(explainBlock);
105
+ let placement;
106
+ if (blocks.length === 2) {
107
+ if (options.heights) {
108
+ const [firstHeight, secondHeight] = options.heights;
109
+ const decision = choosePlacement({
110
+ available: MATRIX_LAYOUT.laptopContentWidth,
111
+ gap: CATALOG_LAYOUT.blockGap,
112
+ first: toPlacementBlock(blocks[0], firstHeight, MATRIX_LAYOUT.laptopContentWidth),
113
+ second: toPlacementBlock(blocks[1], secondHeight, MATRIX_LAYOUT.laptopContentWidth),
114
+ });
115
+ placement = {
116
+ decision,
117
+ reason: `With heights ${firstHeight}px/${secondHeight}px on a 1280px laptop (${MATRIX_LAYOUT.laptopContentWidth}px content width): ${decision}.`,
118
+ };
119
+ }
120
+ else {
121
+ placement = {
122
+ decision: 'decided-in-viewer',
123
+ reason: "Side-by-side placement depends on each block's measured height — decided in the viewer at runtime. Pass --heights <first>,<second> to check it here.",
124
+ };
125
+ }
126
+ }
127
+ return { pageId, blocks: explained, placement };
128
+ }
129
+ export function formatExplain(result) {
130
+ if (result.notCheckable) {
131
+ return `${result.pageId}\n\nPage not statically checkable — some of its layout data is not a literal doctor can read.`;
132
+ }
133
+ const lines = [result.pageId, ''];
134
+ for (const block of result.blocks) {
135
+ lines.push(block.title);
136
+ lines.push(` ${block.reason}`);
137
+ lines.push('');
138
+ }
139
+ if (result.placement) {
140
+ lines.push('Placement');
141
+ lines.push(` ${result.placement.reason}`);
142
+ }
143
+ return lines.join('\n').replace(/\n+$/, '');
144
+ }
145
+ /** `"80,300"` → `[80, 300]`; undefined for anything else (not exactly two positive numbers). */
146
+ export function parseHeights(raw) {
147
+ const parts = raw.split(',').map((part) => Number(part.trim()));
148
+ if (parts.length !== 2 || parts.some((n) => !Number.isFinite(n) || n <= 0))
149
+ return undefined;
150
+ return [parts[0], parts[1]];
151
+ }