@krapwoo/ds-viewer 0.4.8 → 0.5.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/README.md +43 -0
- package/dist/cli/dev.js +66 -37
- package/dist/cli/doctor.js +50 -2
- package/dist/cli/endpoint.js +33 -0
- package/dist/cli/main.js +11 -2
- package/dist/cli/render.js +247 -0
- package/dist/cli/staticPage.js +10 -4
- package/native/catalog/CatalogButton.tsx +46 -0
- package/native/catalog/OverlayDemo.tsx +52 -0
- package/native/catalog/PhoneScreen.tsx +51 -0
- package/native/catalog/UpdatePanel.tsx +114 -103
- package/native/catalog/index.ts +3 -0
- package/native/catalog/overlayDemoState.ts +8 -0
- package/native/catalog/updatePanelState.ts +68 -1
- package/package.json +1 -1
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +38 -48
- package/starter-kit/components/Dialog/Dialog.catalog.tsx +16 -16
- package/starter-kit/components/Toast/Toast.catalog.tsx +9 -10
- package/starter-kit/pages/ReportIssue.catalog.tsx +17 -13
- package/starter-kit/pages/SavedTrips.catalog.tsx +114 -119
package/README.md
CHANGED
|
@@ -272,6 +272,28 @@ actually apply:
|
|
|
272
272
|
in an iPhone SE viewport (375 × 667 points), scaled down only when its cell is narrower. In the
|
|
273
273
|
browser viewer it opens that example in its own device-sized document, so overlays that portal
|
|
274
274
|
to the page (React Native's `Modal`) open inside the phone, not over the catalog.
|
|
275
|
+
- **`OverlayDemo`** — a phone with a catalog button that opens an overlay. Children get
|
|
276
|
+
`{ open, close }`; keep the overlay mounted and drive its own `visible` from `open`, so its
|
|
277
|
+
animations play. `keepTrigger` makes the button a toggle; `triggerLabel` can follow the state;
|
|
278
|
+
`initiallyOpen` starts it open.
|
|
279
|
+
```tsx
|
|
280
|
+
<OverlayDemo triggerLabel="Open sheet">
|
|
281
|
+
{({ open, close }) => (
|
|
282
|
+
<BottomSheet visible={open} onDismiss={close} footer={<Dock><Button label="Confirm" onPress={close} /></Dock>}>
|
|
283
|
+
…
|
|
284
|
+
</BottomSheet>
|
|
285
|
+
)}
|
|
286
|
+
</OverlayDemo>
|
|
287
|
+
```
|
|
288
|
+
- **`PhoneScreen`** — a whole app screen inside a `PhoneFrame`: `header` stays at the top,
|
|
289
|
+
`footer` at the phone's bottom, and the children scroll between them instead of pushing the
|
|
290
|
+
footer off the screen. `floating` is a layer over the top for toasts (position its content
|
|
291
|
+
yourself); `bodyWrapper` puts your own surface around the scrolling body.
|
|
292
|
+
```tsx
|
|
293
|
+
<PhoneFrame>
|
|
294
|
+
<PhoneScreen header={<TopNav title="Saved trips" />} footer={<Dock>…</Dock>}>…</PhoneScreen>
|
|
295
|
+
</PhoneFrame>
|
|
296
|
+
```
|
|
275
297
|
- Full-width (`itemsFill`) variants and states wrap at most **3** across; set `maxColumns` to
|
|
276
298
|
change that. A filled example that keeps its own fixed width is centred in its cell.
|
|
277
299
|
Default to one preview; add a second width only when a narrower (or wider) viewport actually
|
|
@@ -463,6 +485,27 @@ Button
|
|
|
463
485
|
dynamically-constructed references remain uncheckable rather than being guessed at. A
|
|
464
486
|
declared `intentionalStaticPreview` is taken as the author's own call, not independently
|
|
465
487
|
verified; only a real browser check confirms actual behavior.
|
|
488
|
+
- `overlay-without-device-frame` (warning) — the component imports React Native's `Modal`, which
|
|
489
|
+
opens over the whole catalog page on the web, but its page imports no `PhoneFrame`,
|
|
490
|
+
`OverlayDemo`, `PhoneScreen` or `BoundedOverlayViewport`. Conservative: it can't see
|
|
491
|
+
`import * as RN`, a re-exported `Modal`, or a Modal opened by a sub-component.
|
|
492
|
+
|
|
493
|
+
### `doctor --render`
|
|
494
|
+
|
|
495
|
+
`npx ds-viewer doctor --render` also starts the viewer, opens every page in a headless browser,
|
|
496
|
+
and adds what only a real render shows:
|
|
497
|
+
|
|
498
|
+
- `render-error` / `render-no-heading` (errors): a console error, or a page that didn't render.
|
|
499
|
+
- `render-layout` (warning): an example wider than its cell (the viewer's own check), with the
|
|
500
|
+
page and file. `render-catalog-warning` (warning): any other `[Catalog]` warning, reported once.
|
|
501
|
+
- `render-unavailable` / `render-failed` (errors): no browser, or the catalog didn't start.
|
|
502
|
+
|
|
503
|
+
It needs `puppeteer` in your project (`npm install --save-dev puppeteer`); ds-viewer doesn't
|
|
504
|
+
install a browser. It takes about two minutes for 50 pages and works with `--json` (progress
|
|
505
|
+
goes to stderr), `--ci` and `doctor.strict`, so CI can run `npx ds-viewer doctor --render --ci`.
|
|
506
|
+
It stops the viewer it started, including on Ctrl-C (exit codes 130, 143 or 129 for SIGINT,
|
|
507
|
+
SIGTERM or SIGHUP). On Windows it stops the viewer with `taskkill`; that path is unit-tested, not
|
|
508
|
+
run on a real Windows machine.
|
|
466
509
|
|
|
467
510
|
## Known gaps
|
|
468
511
|
|
package/dist/cli/dev.js
CHANGED
|
@@ -106,6 +106,7 @@ function packageManagerLabel(pm) {
|
|
|
106
106
|
* (the endpoint, Task 12, refuses `POST /update` itself); this function only ever runs a plan it
|
|
107
107
|
* has already been told is safe to apply. */
|
|
108
108
|
export async function performUpdate(config, currentVersion, plan, deps) {
|
|
109
|
+
const startAt = deps.from === 'doctor' ? 3 : deps.from === 'migrate' ? 2 : 1;
|
|
109
110
|
const migrationCount = plan.files.filter((f) => f.reason.startsWith('migration:')).length;
|
|
110
111
|
const pm = detectPackageManager(config.projectRoot);
|
|
111
112
|
// Names the detected package manager and the real migration count (Important finding, Fable
|
|
@@ -133,30 +134,34 @@ export async function performUpdate(config, currentVersion, plan, deps) {
|
|
|
133
134
|
state: i < completedCount ? 'done' : i === completedCount ? 'now' : 'todo',
|
|
134
135
|
})),
|
|
135
136
|
});
|
|
136
|
-
report(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
await deps.execImpl('node', [installedMainJs(config.projectRoot), 'migrate', '--from', currentVersion], { cwd: config.projectRoot, encoding: 'utf8' });
|
|
137
|
+
report(startAt);
|
|
138
|
+
if (startAt <= 1) {
|
|
139
|
+
const { command, args } = installUpgradeCommand(pm, '@krapwoo/ds-viewer', plan.latest);
|
|
140
|
+
try {
|
|
141
|
+
await deps.execImpl(command, args, { cwd: config.projectRoot, encoding: 'utf8' });
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[1].failLabel ?? stepDefs[1].label, step: 'install' });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
report(2);
|
|
148
148
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
149
|
+
if (startAt <= 2) {
|
|
150
|
+
try {
|
|
151
|
+
await deps.execImpl('node', [installedMainJs(config.projectRoot), 'migrate', '--from', currentVersion], { cwd: config.projectRoot, encoding: 'utf8' });
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[2].label, step: 'migrate' });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
report(3);
|
|
152
158
|
}
|
|
153
|
-
report(3);
|
|
154
159
|
let doctorOutput;
|
|
155
160
|
try {
|
|
156
161
|
doctorOutput = await deps.execImpl('node', [installedMainJs(config.projectRoot), 'doctor'], { cwd: config.projectRoot, encoding: 'utf8' });
|
|
157
162
|
}
|
|
158
163
|
catch (error) {
|
|
159
|
-
deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[3].label });
|
|
164
|
+
deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[3].label, step: 'doctor' });
|
|
160
165
|
return;
|
|
161
166
|
}
|
|
162
167
|
report(4);
|
|
@@ -331,32 +336,56 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
331
336
|
let child;
|
|
332
337
|
const secret = randomBytes(24).toString('hex');
|
|
333
338
|
const allowedOrigin = `http://localhost:${port}`;
|
|
339
|
+
// The plan the last update ran with, so a failed migrate or doctor step can be resumed.
|
|
340
|
+
let lastPlan;
|
|
341
|
+
const runUpdate = (plan, from) => {
|
|
342
|
+
lastPlan = plan;
|
|
343
|
+
watchersPaused = true;
|
|
344
|
+
void performUpdate(config, ownVersion, plan, {
|
|
345
|
+
execImpl: defaultExecImpl,
|
|
346
|
+
from,
|
|
347
|
+
onStatus: (status) => {
|
|
348
|
+
updateStatus = status;
|
|
349
|
+
if (status.phase === 'failure')
|
|
350
|
+
watchersPaused = false; // the update stopped — keep developing on the old version.
|
|
351
|
+
},
|
|
352
|
+
restart: async (result) => {
|
|
353
|
+
writeFileSync(statusFile, JSON.stringify({ phase: 'success', ...result }));
|
|
354
|
+
await performRestart({ projectRoot: config.projectRoot, port, child, watchers, endpointServer, doctorSummary: result.doctorSummary, files: result.files });
|
|
355
|
+
},
|
|
356
|
+
}).catch((error) => {
|
|
357
|
+
// `performUpdate` only reports 'failure' from its own guarded steps; anything thrown outside
|
|
358
|
+
// them (e.g. writing the status file) lands here instead of as an unhandled rejection.
|
|
359
|
+
updateStatus = { phase: 'failure', log: error.message, failedStep: 'Updating', step: 'other' };
|
|
360
|
+
watchersPaused = false;
|
|
361
|
+
});
|
|
362
|
+
};
|
|
334
363
|
const endpointDeps = {
|
|
335
364
|
secret,
|
|
336
365
|
allowedOrigin,
|
|
337
366
|
buildPlan: () => buildUpdatePlan(config, ownVersion),
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
367
|
+
// Install, migrate, doctor, restart; `from` resumes after a failed migrate or doctor step.
|
|
368
|
+
startUpdate: (plan) => runUpdate(plan),
|
|
369
|
+
getStatus: () => updateStatus,
|
|
370
|
+
dismissStatus: () => {
|
|
371
|
+
updateStatus = { phase: 'idle' };
|
|
372
|
+
},
|
|
373
|
+
resumeUpdate: (step) => {
|
|
374
|
+
if (lastPlan) {
|
|
375
|
+
runUpdate(lastPlan, step);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
// No plan in memory (this process didn't run the failed update): rebuild it first.
|
|
379
|
+
updateStatus = { phase: 'updating', steps: [{ label: 'Preparing', state: 'now' }] };
|
|
380
|
+
void buildUpdatePlan(config, ownVersion).then((plan) => {
|
|
381
|
+
if ('error' in plan)
|
|
382
|
+
updateStatus = { phase: 'failure', log: plan.error, failedStep: 'Preparing', step: 'other' };
|
|
383
|
+
else
|
|
384
|
+
runUpdate(plan, step);
|
|
385
|
+
}, (error) => {
|
|
386
|
+
updateStatus = { phase: 'failure', log: error.message, failedStep: 'Preparing', step: 'other' };
|
|
357
387
|
});
|
|
358
388
|
},
|
|
359
|
-
getStatus: () => updateStatus,
|
|
360
389
|
getVersionStatus: version.getVersionStatus,
|
|
361
390
|
checkNow: version.checkNow,
|
|
362
391
|
setAutoCheck: version.setAutoCheck,
|
package/dist/cli/doctor.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { discoverAllPageFiles, resolvePageId } from "./pageIndex.js";
|
|
5
5
|
import { readHostTsconfigOptions } from "./props.js";
|
|
6
|
-
import { readStaticPages } from "./staticPage.js";
|
|
6
|
+
import { importedNames, readStaticPages } from "./staticPage.js";
|
|
7
7
|
import { resolveGlob } from "./glob.js";
|
|
8
8
|
import { sync } from "./sync.js";
|
|
9
9
|
import { gridColumnLimit } from "../native/catalog/comparison.js";
|
|
@@ -208,6 +208,43 @@ function checkMissingComposition(pages, components) {
|
|
|
208
208
|
}
|
|
209
209
|
return issues;
|
|
210
210
|
}
|
|
211
|
+
/** The viewer exports that frame an example as a phone (or another bounded document), so an
|
|
212
|
+
* overlay that portals to the document body opens inside it instead of over the catalog. */
|
|
213
|
+
const DEVICE_FRAME_EXPORTS = ['PhoneFrame', 'OverlayDemo', 'PhoneScreen', 'BoundedOverlayViewport'];
|
|
214
|
+
/** Whether a component's own source imports React Native's `Modal` (by its imported name, so an
|
|
215
|
+
* alias still counts and an unrelated local `Modal` doesn't). Conservative by design: it can't see
|
|
216
|
+
* `import * as RN`, a re-exported `Modal`, or a Modal opened by a sub-component. */
|
|
217
|
+
function importsModal(file) {
|
|
218
|
+
try {
|
|
219
|
+
const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.ES2022, true, ts.ScriptKind.TSX);
|
|
220
|
+
return [...importedNames(source, 'react-native').values()].includes('Modal');
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** `overlay-without-device-frame`: a page whose component opens a React Native `Modal` (which
|
|
227
|
+
* portals to the document body on the web, covering the whole catalog) but whose page imports no
|
|
228
|
+
* device frame. A page whose imports couldn't be read is skipped, never assumed unframed. */
|
|
229
|
+
export function checkOverlaysFramed(pages, components, projectRoot) {
|
|
230
|
+
const byName = new Map(components.map((c) => [c.name, c]));
|
|
231
|
+
const issues = [];
|
|
232
|
+
for (const page of pages) {
|
|
233
|
+
if (!page.component || page.viewerImports === undefined)
|
|
234
|
+
continue;
|
|
235
|
+
if (page.viewerImports.some((name) => DEVICE_FRAME_EXPORTS.includes(name)))
|
|
236
|
+
continue;
|
|
237
|
+
const component = byName.get(page.component);
|
|
238
|
+
if (!component || !importsModal(path.resolve(projectRoot, component.file)))
|
|
239
|
+
continue;
|
|
240
|
+
issues.push(issue({
|
|
241
|
+
id: 'overlay-without-device-frame', severity: 'warning', ...pageRef(page),
|
|
242
|
+
message: `"${component.name}" opens a React Native Modal, which covers the whole catalog page unless its example is inside a phone.`,
|
|
243
|
+
fix: 'Wrap the example in <PhoneFrame> (or use <OverlayDemo>), so it opens inside the phone.',
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
return issues;
|
|
247
|
+
}
|
|
211
248
|
/** A function-typed prop's printed type always contains `=>` (`cli/props.ts`'s own displayed type
|
|
212
249
|
* strings — "(value: string) => void", "() => void", …); a plain data prop's never does. */
|
|
213
250
|
function isFunctionPropType(type) {
|
|
@@ -509,6 +546,15 @@ export function checkPageTypeErrors(pages, options = {}) {
|
|
|
509
546
|
}
|
|
510
547
|
return issues;
|
|
511
548
|
}
|
|
549
|
+
/** Adds findings to a finished result: applies `doctor.strict`, recounts errors and warnings, and
|
|
550
|
+
* resets the exit code. */
|
|
551
|
+
export function withExtraIssues(result, extra, strict) {
|
|
552
|
+
const added = strict ? extra.map((i) => (i.severity === 'warning' ? { ...i, severity: 'error' } : i)) : extra;
|
|
553
|
+
const issues = [...result.issues, ...added];
|
|
554
|
+
const errors = issues.filter((i) => i.severity === 'error').length;
|
|
555
|
+
const summary = { ...result.summary, errors, warnings: issues.length - errors };
|
|
556
|
+
return { ...result, issues, summary, exitCode: errors > 0 ? 1 : 0 };
|
|
557
|
+
}
|
|
512
558
|
/** "With examples" means what `checkComponentNoExamples` (Task 4) checks the *absence* of: at
|
|
513
559
|
* least one checkable page with a real example, not merely "has a page" (an `init`-drafted
|
|
514
560
|
* "Needs examples" page has one, with none). */
|
|
@@ -572,6 +618,7 @@ export function runDoctor(config) {
|
|
|
572
618
|
let issues = [
|
|
573
619
|
...collectIssues(pages, components),
|
|
574
620
|
...checkPageTypeErrors(pages, { fallbackPaths, hostRoot: config.projectRoot }),
|
|
621
|
+
...checkOverlaysFramed(pages, components, config.projectRoot),
|
|
575
622
|
...collectGlobalIssues({ syncWarnings: syncResult.warnings, tokenFileCount }),
|
|
576
623
|
];
|
|
577
624
|
// Design's own `--json` example gives a project-relative `file`; `StaticPage.file` is absolute
|
|
@@ -588,7 +635,8 @@ export function runDoctor(config) {
|
|
|
588
635
|
withExamples: countWithExamples(pages, components),
|
|
589
636
|
unboundExamples: countUnboundExamples(pages),
|
|
590
637
|
};
|
|
591
|
-
|
|
638
|
+
const pageFileById = new Map(pages.map((p) => [resolvePageId(p, p.file), path.relative(config.projectRoot, p.file)]));
|
|
639
|
+
return { version: 1, summary, update: null, issues, exitCode: summary.errors > 0 ? 1 : 0, pageFiles: pageFileById };
|
|
592
640
|
}
|
|
593
641
|
export function toDoctorJson(result) {
|
|
594
642
|
const { version, summary, update, issues } = result;
|
package/dist/cli/endpoint.js
CHANGED
|
@@ -166,6 +166,39 @@ export function createUpdateEndpoint(deps) {
|
|
|
166
166
|
});
|
|
167
167
|
return;
|
|
168
168
|
}
|
|
169
|
+
if (req.method === 'POST' && req.url === '/update/dismiss' && deps.dismissStatus) {
|
|
170
|
+
const phase = deps.getStatus().phase;
|
|
171
|
+
if (starting || phase === 'updating' || phase === 'restarting') {
|
|
172
|
+
sendJson(res, 409, { error: 'An update is running.' }, deps.allowedOrigin);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
deps.dismissStatus();
|
|
176
|
+
sendJson(res, 200, deps.getStatus(), deps.allowedOrigin);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (req.method === 'POST' && req.url === '/update/resume' && deps.resumeUpdate) {
|
|
180
|
+
const status = deps.getStatus();
|
|
181
|
+
const step = status.phase === 'failure' ? status.step : undefined;
|
|
182
|
+
if (starting || (step !== 'migrate' && step !== 'doctor')) {
|
|
183
|
+
sendJson(res, 409, { error: 'There is no failed migrate or doctor step to resume.' }, deps.allowedOrigin);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// Same reasoning as POST /update: answer first, then run the work on the next tick.
|
|
187
|
+
starting = true;
|
|
188
|
+
sendJson(res, 202, { started: true }, deps.allowedOrigin);
|
|
189
|
+
setImmediate(() => {
|
|
190
|
+
try {
|
|
191
|
+
deps.resumeUpdate(step);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
console.warn('Update could not resume: ' + error.message);
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
starting = false;
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
169
202
|
if (req.method === 'GET' && req.url === '/update/status') {
|
|
170
203
|
sendJson(res, 200, deps.getStatus(), deps.allowedOrigin);
|
|
171
204
|
return;
|
package/dist/cli/main.js
CHANGED
|
@@ -5,7 +5,8 @@ import { resolveConfig } from "./config.js";
|
|
|
5
5
|
import { initExistingProject, initNewProject, promptInitMode } from "./init.js";
|
|
6
6
|
import { dev } from "./dev.js";
|
|
7
7
|
import { sync } from "./sync.js";
|
|
8
|
-
import { formatHuman, runDoctor, toDoctorJson } from "./doctor.js";
|
|
8
|
+
import { formatHuman, runDoctor, toDoctorJson, withExtraIssues } from "./doctor.js";
|
|
9
|
+
import { renderCheck } from "./render.js";
|
|
9
10
|
import { explainPage, formatExplain, parseHeights } from "./explain.js";
|
|
10
11
|
import { readOwnVersion } from "./packageVersion.js";
|
|
11
12
|
import { checkForUpdate, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck } from "./updateCheck.js";
|
|
@@ -27,6 +28,7 @@ Commands:
|
|
|
27
28
|
doctor Check every page for drift and coverage gaps
|
|
28
29
|
--json print the machine-readable report instead
|
|
29
30
|
--ci exit 1 when any error was found (never on a warning alone)
|
|
31
|
+
--render also open every page in a headless browser (needs puppeteer)
|
|
30
32
|
explain <Page>
|
|
31
33
|
Print why a page's specimens are laid out the way they are
|
|
32
34
|
--heights <first>,<second> check side-by-side placement with these measured heights
|
|
@@ -116,7 +118,14 @@ export async function runInit(projectRoot, rest, options = {}) {
|
|
|
116
118
|
* plain `doctor` (no `--ci`) always returns 0 here regardless of errors found. */
|
|
117
119
|
export async function runDoctorCommand(projectRoot, rest, options = {}) {
|
|
118
120
|
const config = resolveConfig(projectRoot);
|
|
119
|
-
|
|
121
|
+
let result = runDoctor(config);
|
|
122
|
+
// `--render`: open every page in a headless browser and add what only a real render shows.
|
|
123
|
+
// Progress goes to stderr, so `--json` output stays pure JSON.
|
|
124
|
+
if (rest.includes('--render')) {
|
|
125
|
+
const files = result.pageFiles;
|
|
126
|
+
const rendered = await (options.renderCheck ?? renderCheck)(config.projectRoot, { files, log: (line) => process.stderr.write(`${line}\n`) });
|
|
127
|
+
result = withExtraIssues(result, rendered, Boolean(config.doctor?.strict));
|
|
128
|
+
}
|
|
120
129
|
// Design §4 "`--ci`: ... no update check." — `--ci` skips this whole block even when
|
|
121
130
|
// `updateCheck` is otherwise enabled; `result.update` stays `null`.
|
|
122
131
|
// Same precedence as the viewer: CI's environment variable, your own switch, the project config.
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// cli/render.ts — `doctor --render`: open every catalog page in a headless browser and report what
|
|
2
|
+
// only a real render shows (console errors, pages that fail to render, the viewer's own [Catalog]
|
|
3
|
+
// layout warnings). The browser is resolved from the project; ds-viewer never installs one.
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
const OVERFLOW = /is \d+px wide in a \d+px cell/;
|
|
9
|
+
/**
|
|
10
|
+
* Turns one page's console output into doctor findings. Console errors and a missing heading are
|
|
11
|
+
* errors. The viewer's own `[Catalog]` warnings are warnings: `render-layout` for an example that
|
|
12
|
+
* overflows its cell, `render-catalog-warning` for anything else. Other console noise (React Native
|
|
13
|
+
* web deprecations, logs) is ignored. `seen` is shared across the whole run, so a warning the
|
|
14
|
+
* catalog logs on every page (a duplicate id, an unknown `composedOf` name) is reported once.
|
|
15
|
+
*/
|
|
16
|
+
export function issuesFromPage(page, messages, hasHeading, seen = new Set()) {
|
|
17
|
+
const ref = { page: page.id, ...(page.file ? { file: page.file } : {}) };
|
|
18
|
+
const issues = [];
|
|
19
|
+
if (!hasHeading) {
|
|
20
|
+
issues.push({ id: 'render-no-heading', severity: 'error', ...ref, message: 'The page rendered without its heading, so it probably failed to render.', fix: 'Open the page in `ds-viewer dev` and check the browser console.' });
|
|
21
|
+
}
|
|
22
|
+
const pageErrors = new Set();
|
|
23
|
+
for (const m of messages) {
|
|
24
|
+
const first = m.text.split('\n')[0];
|
|
25
|
+
if (m.type === 'error' || m.type === 'pageerror') {
|
|
26
|
+
if (pageErrors.has(first))
|
|
27
|
+
continue;
|
|
28
|
+
pageErrors.add(first);
|
|
29
|
+
issues.push({ id: 'render-error', severity: 'error', ...ref, message: `Console error while rendering: ${first}`, fix: 'Open the page in `ds-viewer dev` and fix the error in the browser console.' });
|
|
30
|
+
}
|
|
31
|
+
else if ((m.type === 'warn' || m.type === 'warning') && first.startsWith('[Catalog] ')) {
|
|
32
|
+
const text = first.slice('[Catalog] '.length);
|
|
33
|
+
if (seen.has(text))
|
|
34
|
+
continue;
|
|
35
|
+
seen.add(text);
|
|
36
|
+
issues.push(OVERFLOW.test(text)
|
|
37
|
+
? { id: 'render-layout', severity: 'warning', ...ref, message: text, fix: 'Give the page a wider specimenSize; see the README page guide.' }
|
|
38
|
+
: { id: 'render-catalog-warning', severity: 'warning', ...ref, message: text, fix: 'Follow the message; the static doctor rule of the same name has the details.' });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return issues;
|
|
42
|
+
}
|
|
43
|
+
/** The project's own puppeteer, or `undefined` when it isn't installed. */
|
|
44
|
+
export function loadProjectPuppeteer(projectRoot) {
|
|
45
|
+
try {
|
|
46
|
+
return createRequire(path.join(path.resolve(projectRoot), 'package.json'))('puppeteer');
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export const RENDER_UNAVAILABLE = {
|
|
53
|
+
id: 'render-unavailable',
|
|
54
|
+
severity: 'error',
|
|
55
|
+
message: "`doctor --render` needs a headless browser, and puppeteer isn't installed in this project.",
|
|
56
|
+
fix: 'npm install --save-dev puppeteer (then run doctor --render again).',
|
|
57
|
+
};
|
|
58
|
+
const DEV_START_TIMEOUT_MS = 120_000;
|
|
59
|
+
const FIRST_LOAD_TIMEOUT_MS = 300_000;
|
|
60
|
+
const PAGE_TIMEOUT_MS = 120_000;
|
|
61
|
+
const SETTLE_MS = 900;
|
|
62
|
+
/** Stops `dev` and everything it started (Expo/Metro): the whole process tree on Windows, the
|
|
63
|
+
* whole process group elsewhere (Expo stops through dev's own signal handler). Releases the pipes
|
|
64
|
+
* so this process can exit. `os` is injectable for tests. */
|
|
65
|
+
export function stopDev(child, os = {}) {
|
|
66
|
+
const platform = os.platform ?? process.platform;
|
|
67
|
+
if (child.pid) {
|
|
68
|
+
try {
|
|
69
|
+
if (platform === 'win32')
|
|
70
|
+
(os.spawnSync ?? spawnSync)('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
71
|
+
else
|
|
72
|
+
(os.kill ?? ((pid, signal) => process.kill(pid, signal)))(-child.pid, 'SIGTERM');
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Already exited.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
child.stdout?.destroy();
|
|
79
|
+
child.stderr?.destroy();
|
|
80
|
+
child.unref();
|
|
81
|
+
}
|
|
82
|
+
/** The last few KB of dev's output, so a failed start can say why. */
|
|
83
|
+
function outputTail() {
|
|
84
|
+
let tail = '';
|
|
85
|
+
return {
|
|
86
|
+
push: (data) => {
|
|
87
|
+
tail = (tail + String(data)).slice(-4000);
|
|
88
|
+
},
|
|
89
|
+
text: () => {
|
|
90
|
+
const lines = tail.trim().split('\n').slice(-8).join('\n');
|
|
91
|
+
return lines ? `\n${lines}` : '';
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Starts the project's own `dev` in its own process group. `onSpawn` receives the child at once,
|
|
96
|
+
* so a signal during startup can still stop it. */
|
|
97
|
+
function startDev(projectRoot, onSpawn) {
|
|
98
|
+
const main = path.join(path.dirname(fileURLToPath(import.meta.url)), 'main.js');
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const child = spawn(process.execPath, [main, 'dev'], {
|
|
101
|
+
cwd: projectRoot,
|
|
102
|
+
env: { ...process.env, CI: '1', DS_VIEWER_NO_UPDATE_CHECK: '1' },
|
|
103
|
+
detached: process.platform !== 'win32',
|
|
104
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
105
|
+
});
|
|
106
|
+
onSpawn(child);
|
|
107
|
+
const output = outputTail();
|
|
108
|
+
let done = false;
|
|
109
|
+
const fail = (message) => {
|
|
110
|
+
if (done)
|
|
111
|
+
return;
|
|
112
|
+
done = true;
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
stopDev(child);
|
|
115
|
+
reject(new Error(`${message}${output.text()}`));
|
|
116
|
+
};
|
|
117
|
+
const timer = setTimeout(() => fail('Timed out waiting for ds-viewer dev to start.'), DEV_START_TIMEOUT_MS);
|
|
118
|
+
// Read continuously (so Metro's output never fills the pipe) and keep the tail for errors.
|
|
119
|
+
child.stderr?.on('data', output.push);
|
|
120
|
+
child.stdout?.on('data', (data) => {
|
|
121
|
+
output.push(data);
|
|
122
|
+
const match = String(data).match(/Starting the catalog at http:\/\/localhost:(\d+)/);
|
|
123
|
+
if (match && !done) {
|
|
124
|
+
done = true;
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
resolve({ child, port: Number(match[1]) });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
child.on('exit', (code) => fail(`ds-viewer dev exited early (code ${code}).`));
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
|
|
133
|
+
/** A sidebar href's page id; a malformed escape falls back to the raw text (like `idFromHash`). */
|
|
134
|
+
function idFromHref(href) {
|
|
135
|
+
const raw = href.slice(1);
|
|
136
|
+
try {
|
|
137
|
+
return decodeURIComponent(raw);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return raw;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Opens every sidebar page and returns its findings. `files` maps page ids to project-relative
|
|
144
|
+
* page files, for the findings' `file`. `log` receives progress lines (stderr in the CLI). `deps`
|
|
145
|
+
* replaces the browser, dev process control and exit for tests. */
|
|
146
|
+
export async function renderCheck(projectRoot, options = {}) {
|
|
147
|
+
projectRoot = path.resolve(projectRoot);
|
|
148
|
+
const deps = options.deps ?? {};
|
|
149
|
+
const puppeteer = (deps.puppeteer ?? loadProjectPuppeteer(projectRoot));
|
|
150
|
+
if (!puppeteer)
|
|
151
|
+
return [RENDER_UNAVAILABLE];
|
|
152
|
+
const stop = deps.stopDev ?? ((child) => stopDev(child));
|
|
153
|
+
const exit = deps.exit ?? ((code) => process.exit(code));
|
|
154
|
+
const log = options.log ?? (() => { });
|
|
155
|
+
// Set as soon as dev is spawned, before it's ready, so a signal during startup still stops it.
|
|
156
|
+
let devChild;
|
|
157
|
+
let port = 0;
|
|
158
|
+
let browser;
|
|
159
|
+
// Ctrl-C or a CI cancel must still stop Metro: `dev` runs in its own process group, so the
|
|
160
|
+
// terminal's signal never reaches it. Registered before the browser launches, so it runs ahead
|
|
161
|
+
// of puppeteer's own handler.
|
|
162
|
+
const handlers = Object.keys(SIGNAL_EXIT_CODES).map((signal) => {
|
|
163
|
+
const handler = () => {
|
|
164
|
+
if (devChild)
|
|
165
|
+
stop(devChild);
|
|
166
|
+
exit(SIGNAL_EXIT_CODES[signal]);
|
|
167
|
+
};
|
|
168
|
+
process.on(signal, handler);
|
|
169
|
+
return [signal, handler];
|
|
170
|
+
});
|
|
171
|
+
try {
|
|
172
|
+
// The browser first: a missing browser download shouldn't start Metro for nothing.
|
|
173
|
+
try {
|
|
174
|
+
browser = await puppeteer.launch({ headless: true });
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
return [{ ...RENDER_UNAVAILABLE, message: `Couldn't start the headless browser: ${error.message.split('\n')[0]}`, fix: 'npx puppeteer browsers install chrome' }];
|
|
178
|
+
}
|
|
179
|
+
({ port } = await (deps.startDev ?? startDev)(projectRoot, (child) => {
|
|
180
|
+
devChild = child;
|
|
181
|
+
}));
|
|
182
|
+
const home = await browser.newPage();
|
|
183
|
+
const deadline = Date.now() + FIRST_LOAD_TIMEOUT_MS;
|
|
184
|
+
for (;;) {
|
|
185
|
+
try {
|
|
186
|
+
await home.goto(`http://localhost:${port}`, { waitUntil: 'networkidle0', timeout: FIRST_LOAD_TIMEOUT_MS });
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (!String(error).includes('ERR_CONNECTION_REFUSED') || Date.now() > deadline)
|
|
191
|
+
throw error;
|
|
192
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Runs in the browser; `document` is typed loosely because the CLI compiles without DOM types.
|
|
196
|
+
const hrefs = await home.evaluate(() => {
|
|
197
|
+
const doc = globalThis.document;
|
|
198
|
+
return [...new Set(Array.from(doc.querySelectorAll('a[href^="#"]')).map((a) => a.getAttribute('href') ?? ''))];
|
|
199
|
+
});
|
|
200
|
+
await home.close();
|
|
201
|
+
const ids = hrefs.map(idFromHref).filter(Boolean);
|
|
202
|
+
if (ids.length === 0) {
|
|
203
|
+
return [{ id: 'render-failed', severity: 'error', message: 'The catalog rendered no sidebar links, so it probably failed to render at all.', fix: 'Run `ds-viewer dev` and check the browser console.' }];
|
|
204
|
+
}
|
|
205
|
+
log(`Rendering ${ids.length} pages…`);
|
|
206
|
+
const seen = new Set();
|
|
207
|
+
const issues = [];
|
|
208
|
+
for (const id of ids) {
|
|
209
|
+
// One page failing (a navigation timeout) is that page's finding; the others still run.
|
|
210
|
+
let page;
|
|
211
|
+
try {
|
|
212
|
+
page = await browser.newPage();
|
|
213
|
+
await page.setViewport({ width: 1280, height: 800 });
|
|
214
|
+
const messages = [];
|
|
215
|
+
page.on('console', (m) => messages.push({ type: m.type(), text: m.text() }));
|
|
216
|
+
page.on('pageerror', (e) => messages.push({ type: 'pageerror', text: String(e) }));
|
|
217
|
+
await page.goto(`http://localhost:${port}/#${encodeURIComponent(id)}`, { waitUntil: 'networkidle0', timeout: PAGE_TIMEOUT_MS });
|
|
218
|
+
await new Promise((r) => setTimeout(r, options.settleMs ?? SETTLE_MS));
|
|
219
|
+
const hasHeading = await page.evaluate(() => {
|
|
220
|
+
const doc = globalThis.document;
|
|
221
|
+
return Boolean(doc.querySelector('[role="heading"]')?.textContent?.trim());
|
|
222
|
+
});
|
|
223
|
+
issues.push(...issuesFromPage({ id, file: options.files?.get(id) }, messages, hasHeading, seen));
|
|
224
|
+
await page.close();
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
// A stuck page's tab is closed, so it doesn't keep loading next to the remaining pages.
|
|
228
|
+
await page?.close().catch(() => { });
|
|
229
|
+
const file = options.files?.get(id);
|
|
230
|
+
issues.push({ id: 'render-error', severity: 'error', page: id, ...(file ? { file } : {}), message: `Couldn't open the page: ${error.message.split('\n')[0]}`, fix: 'Open the page in `ds-viewer dev`; it may be very slow to load or stuck.' });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
log(`Checked ${ids.length} pages.`);
|
|
234
|
+
return issues;
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
return [{ id: 'render-failed', severity: 'error', message: `Couldn't render the catalog: ${error.message}`, fix: "Run `ds-viewer dev` to see why the catalog doesn't start." }];
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
for (const [signal, handler] of handlers)
|
|
241
|
+
process.off(signal, handler);
|
|
242
|
+
if (browser)
|
|
243
|
+
await browser.close().catch(() => { });
|
|
244
|
+
if (devChild)
|
|
245
|
+
stop(devChild);
|
|
246
|
+
}
|
|
247
|
+
}
|
package/dist/cli/staticPage.js
CHANGED
|
@@ -42,6 +42,7 @@ function readStaticPage(file) {
|
|
|
42
42
|
}
|
|
43
43
|
const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TSX);
|
|
44
44
|
const imports = importedNames(sourceFile, '@krapwoo/ds-viewer');
|
|
45
|
+
const viewerImports = [...new Set(imports.values())];
|
|
45
46
|
const defineCatalogPageNames = localNamesFor(imports, 'defineCatalogPage');
|
|
46
47
|
const gridNames = localNamesFor(imports, 'grid');
|
|
47
48
|
const call = defaultExportCall(sourceFile);
|
|
@@ -49,13 +50,13 @@ function readStaticPage(file) {
|
|
|
49
50
|
// Not a recognized `defineCatalogPage(...)` default export — e.g. a hand-written object, or a
|
|
50
51
|
// shadowed/renamed import this reader can't trace. Not a parse error: the file is valid
|
|
51
52
|
// TypeScript, just not literal-checkable.
|
|
52
|
-
return { file, checkable: false };
|
|
53
|
+
return { file, checkable: false, viewerImports };
|
|
53
54
|
}
|
|
54
55
|
const consts = topLevelConsts(sourceFile);
|
|
55
56
|
const pageObject = resolve(call.arguments[0], consts, new Set());
|
|
56
57
|
if (!ts.isObjectLiteralExpression(pageObject))
|
|
57
|
-
return { file, checkable: false };
|
|
58
|
-
const page = { file, checkable: true };
|
|
58
|
+
return { file, checkable: false, viewerImports };
|
|
59
|
+
const page = { file, checkable: true, viewerImports };
|
|
59
60
|
const idProp = findProp(pageObject, 'id');
|
|
60
61
|
const componentProp = findProp(pageObject, 'component');
|
|
61
62
|
const groupProp = findProp(pageObject, 'group');
|
|
@@ -193,16 +194,21 @@ function readComposedOf(node, consts) {
|
|
|
193
194
|
return entries;
|
|
194
195
|
}
|
|
195
196
|
/** Local name -> imported name, for every named import from `moduleSpecifier`. */
|
|
196
|
-
function importedNames(sourceFile, moduleSpecifier) {
|
|
197
|
+
export function importedNames(sourceFile, moduleSpecifier) {
|
|
197
198
|
const names = new Map();
|
|
198
199
|
for (const statement of sourceFile.statements) {
|
|
199
200
|
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== moduleSpecifier) {
|
|
200
201
|
continue;
|
|
201
202
|
}
|
|
203
|
+
// Type-only imports bring no runtime value (`import type { X }`, `import { type X }`).
|
|
204
|
+
if (statement.importClause?.isTypeOnly)
|
|
205
|
+
continue;
|
|
202
206
|
const bindings = statement.importClause?.namedBindings;
|
|
203
207
|
if (!bindings || !ts.isNamedImports(bindings))
|
|
204
208
|
continue;
|
|
205
209
|
for (const element of bindings.elements) {
|
|
210
|
+
if (element.isTypeOnly)
|
|
211
|
+
continue;
|
|
206
212
|
names.set(element.name.text, element.propertyName ? element.propertyName.text : element.name.text);
|
|
207
213
|
}
|
|
208
214
|
}
|