@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 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(1);
137
- const { command, args } = installUpgradeCommand(pm, '@krapwoo/ds-viewer', plan.latest);
138
- try {
139
- await deps.execImpl(command, args, { cwd: config.projectRoot, encoding: 'utf8' });
140
- }
141
- catch (error) {
142
- deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[1].failLabel ?? stepDefs[1].label });
143
- return;
144
- }
145
- report(2);
146
- try {
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
- catch (error) {
150
- deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[2].label });
151
- return;
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
- startUpdate: (plan) => {
339
- watchersPaused = true;
340
- void performUpdate(config, ownVersion, plan, {
341
- execImpl: defaultExecImpl,
342
- onStatus: (status) => {
343
- updateStatus = status;
344
- if (status.phase === 'failure')
345
- watchersPaused = false; // the update stopped — keep developing on the old version.
346
- },
347
- restart: async (result) => {
348
- writeFileSync(statusFile, JSON.stringify({ phase: 'success', ...result }));
349
- await performRestart({ projectRoot: config.projectRoot, port, child, watchers, endpointServer, doctorSummary: result.doctorSummary, files: result.files });
350
- },
351
- }).catch((error) => {
352
- // Minor finding, Fable correction pass: `performUpdate` only ever reports `'failure'` from
353
- // its own guarded steps — a throw outside them (e.g. `writeFileSync(statusFile)` itself
354
- // failing) previously became an unhandled rejection with nothing watching for it.
355
- updateStatus = { phase: 'failure', log: error.message, failedStep: 'Updating' };
356
- watchersPaused = false;
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,
@@ -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
- return { version: 1, summary, update: null, issues, exitCode: summary.errors > 0 ? 1 : 0 };
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;
@@ -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
- const result = runDoctor(config);
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
+ }
@@ -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
  }