@krapwoo/ds-viewer 0.2.0 → 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.
- package/README.md +82 -0
- package/dist/cli/agentsFile.js +31 -0
- package/dist/cli/doctor.js +492 -0
- package/dist/cli/explain.js +151 -0
- package/dist/cli/githubAction.js +68 -0
- package/dist/cli/init.js +6 -2
- package/dist/cli/main.js +49 -0
- package/dist/cli/pageIndex.js +27 -1
- package/dist/cli/props.js +4 -3
- package/dist/cli/staticPage.js +356 -0
- package/dist/cli/sync.js +11 -4
- package/dist/native/catalog/comparison.js +283 -0
- package/dist/native/catalog/tokens.js +119 -0
- package/dist/native/catalog/types.js +1 -0
- package/native/catalog/ComparisonGrid.tsx +9 -7
- package/native/catalog/SpacingScaleGallery.tsx +1 -1
- package/native/catalog/catalogNavigation.ts +1 -1
- package/native/catalog/comparison.ts +63 -5
- package/native/catalog/index.ts +2 -1
- package/native/catalog/pageApi.ts +11 -2
- package/native/catalog/types.ts +9 -6
- package/package.json +5 -1
- package/starter-kit/components/Avatar/Avatar.catalog.tsx +4 -22
- package/starter-kit/components/Badge/Badge.catalog.tsx +15 -23
- package/starter-kit/components/Banner/Banner.tsx +1 -1
- package/starter-kit/components/BottomSheet/BottomSheet.tsx +2 -2
- package/starter-kit/components/Button/Button.catalog.tsx +15 -29
- package/starter-kit/components/Dialog/Dialog.tsx +2 -2
- package/starter-kit/components/Pill/Pill.catalog.tsx +12 -26
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
// Design §4 "GitHub Action" + §5's lockfile-detection assumption. `checkout`/`setup-node` pins
|
|
4
|
+
// reused verbatim from this repository's own `.github/workflows/ci.yml` (Global Constraints);
|
|
5
|
+
// `pnpm/action-setup` is the one new SHA this plan resolves (`gh api
|
|
6
|
+
// repos/pnpm/action-setup/git/refs/tags/v6.1.0`, dereferenced to its commit). `node-version: '20'`
|
|
7
|
+
// matches the package's own published `engines` floor (`>=20.19.0`): a user's project only ever
|
|
8
|
+
// runs the compiled `dist/cli/main.js` via `npx ds-viewer`, which needs no type-stripping (unlike
|
|
9
|
+
// this repo's own CI, which type-strips its *source* and therefore needs Node 22).
|
|
10
|
+
// Errata #5: `pnpm/action-setup` needs an explicit pnpm version — it has no lockfile to infer one
|
|
11
|
+
// from. Read the project's own `packageManager` field (`"pnpm@9.1.0"`) when present; otherwise
|
|
12
|
+
// default to 10, matching the pin's own major version line.
|
|
13
|
+
function pnpmVersion(projectRoot) {
|
|
14
|
+
const packageJsonPath = path.join(projectRoot, 'package.json');
|
|
15
|
+
if (!existsSync(packageJsonPath))
|
|
16
|
+
return '10';
|
|
17
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
18
|
+
const match = packageJson.packageManager?.match(/^pnpm@(\d+)/);
|
|
19
|
+
return match ? match[1] : '10';
|
|
20
|
+
}
|
|
21
|
+
// Important finding 6 (Fable's review): Yarn 1 (still the common Expo lockfile) has no
|
|
22
|
+
// `--immutable` flag — its equivalent is `--frozen-lockfile`. Yarn Berry needs `corepack enable`
|
|
23
|
+
// first, since `actions/setup-node` ships Yarn 1 on `ubuntu-latest`. A v1 lockfile's own first line
|
|
24
|
+
// is always exactly `# yarn lockfile v1`; anything else (Berry's own header/`__metadata`) is Berry.
|
|
25
|
+
function yarnInstallStep(projectRoot) {
|
|
26
|
+
const firstLine = readFileSync(path.join(projectRoot, 'yarn.lock'), 'utf8').split('\n', 1)[0];
|
|
27
|
+
if (firstLine === '# yarn lockfile v1')
|
|
28
|
+
return ' - run: yarn install --frozen-lockfile\n';
|
|
29
|
+
return ' - run: corepack enable\n - run: yarn install --immutable\n';
|
|
30
|
+
}
|
|
31
|
+
function installStep(projectRoot) {
|
|
32
|
+
if (existsSync(path.join(projectRoot, 'pnpm-lock.yaml'))) {
|
|
33
|
+
return ` - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0\n with:\n version: ${pnpmVersion(projectRoot)}\n - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0\n with:\n node-version: '20'\n - run: pnpm install --frozen-lockfile\n`;
|
|
34
|
+
}
|
|
35
|
+
const setupNode = ` - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0\n with:\n node-version: '20'\n`;
|
|
36
|
+
if (existsSync(path.join(projectRoot, 'yarn.lock')))
|
|
37
|
+
return `${setupNode}${yarnInstallStep(projectRoot)}`;
|
|
38
|
+
// `package-lock.json`, or no lockfile at all (a fresh project before its first commit) — `npm
|
|
39
|
+
// ci` is npm's own documented behavior for the latter: it installs from `package.json` and
|
|
40
|
+
// writes a fresh lockfile, exactly like a plain `npm install` would.
|
|
41
|
+
return `${setupNode} - run: npm ci\n`;
|
|
42
|
+
}
|
|
43
|
+
function workflowContent(projectRoot) {
|
|
44
|
+
return `name: DS Viewer
|
|
45
|
+
|
|
46
|
+
on:
|
|
47
|
+
pull_request:
|
|
48
|
+
|
|
49
|
+
jobs:
|
|
50
|
+
doctor:
|
|
51
|
+
runs-on: ubuntu-latest
|
|
52
|
+
steps:
|
|
53
|
+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
54
|
+
${installStep(projectRoot)} - run: npx ds-viewer doctor --ci
|
|
55
|
+
`;
|
|
56
|
+
}
|
|
57
|
+
/** Design §4 "GitHub Action", §1 Ownership: written once, never overwritten — a user's own edits
|
|
58
|
+
* to this file (or a differently-named workflow they wrote instead) are never touched again. The
|
|
59
|
+
* install step is picked from whichever lockfile is present *right now*, at `init` time — never
|
|
60
|
+
* re-picked later, since this function never runs again once the file exists. */
|
|
61
|
+
export function ensureGithubAction(projectRoot) {
|
|
62
|
+
const workflowPath = path.join(projectRoot, '.github', 'workflows', 'ds-viewer.yml');
|
|
63
|
+
if (existsSync(workflowPath))
|
|
64
|
+
return [];
|
|
65
|
+
mkdirSync(path.dirname(workflowPath), { recursive: true });
|
|
66
|
+
writeFileSync(workflowPath, workflowContent(projectRoot));
|
|
67
|
+
return [workflowPath];
|
|
68
|
+
}
|
package/dist/cli/init.js
CHANGED
|
@@ -4,6 +4,8 @@ import { createInterface } from 'node:readline/promises';
|
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
5
|
import { preflight } from "./preflight.js";
|
|
6
6
|
import { detectComponentFolders, detectTokenFiles } from "./detect.js";
|
|
7
|
+
import { ensureAgentsFileSection } from "./agentsFile.js";
|
|
8
|
+
import { ensureGithubAction } from "./githubAction.js";
|
|
7
9
|
async function promptConfirm(message) {
|
|
8
10
|
// A closed, non-interactive stdin (CI, piped npx) would reject `question` with an AbortError;
|
|
9
11
|
// say how to proceed instead.
|
|
@@ -104,8 +106,8 @@ export async function initExistingProject(projectRoot, options = {}) {
|
|
|
104
106
|
return { messages, written };
|
|
105
107
|
}
|
|
106
108
|
/** Design §2, "Both paths then write what is missing": `ds-viewer.config.ts` is written by each
|
|
107
|
-
* path separately (its contents differ), but the package script, the devDependency entry,
|
|
108
|
-
* `.gitignore` entry are identical either way. */
|
|
109
|
+
* path separately (its contents differ), but the package script, the devDependency entry, the
|
|
110
|
+
* `.gitignore` entry, and (from 0.3) the `AGENTS.md` section are identical either way. */
|
|
109
111
|
function ensureSharedProjectFiles(projectRoot) {
|
|
110
112
|
const written = [];
|
|
111
113
|
const packageJsonPath = path.join(projectRoot, 'package.json');
|
|
@@ -134,6 +136,8 @@ function ensureSharedProjectFiles(projectRoot) {
|
|
|
134
136
|
writeFileSync(gitignorePath, `${existingGitignore}${separator}${gitignoreEntry}\n`);
|
|
135
137
|
written.push(gitignorePath);
|
|
136
138
|
}
|
|
139
|
+
written.push(...ensureAgentsFileSection(projectRoot));
|
|
140
|
+
written.push(...ensureGithubAction(projectRoot));
|
|
137
141
|
return written;
|
|
138
142
|
}
|
|
139
143
|
const KIT_REQUIRED_PACKAGES = ['react-native-svg', 'react-native-safe-area-context'];
|
package/dist/cli/main.js
CHANGED
|
@@ -6,6 +6,8 @@ import { resolveConfig } from "./config.js";
|
|
|
6
6
|
import { initExistingProject, initNewProject, promptInitMode } from "./init.js";
|
|
7
7
|
import { dev } from "./dev.js";
|
|
8
8
|
import { sync } from "./sync.js";
|
|
9
|
+
import { formatHuman, runDoctor, toDoctorJson } from "./doctor.js";
|
|
10
|
+
import { explainPage, formatExplain, parseHeights } from "./explain.js";
|
|
9
11
|
const USAGE = `Usage: ds-viewer <command>
|
|
10
12
|
|
|
11
13
|
Commands:
|
|
@@ -16,6 +18,13 @@ Commands:
|
|
|
16
18
|
--yes accept the detected components and tokens without asking (--existing only)
|
|
17
19
|
sync Regenerate the component, props, token, and page data from source
|
|
18
20
|
dev Sync, then serve the catalog on a local web address and keep it current as files change
|
|
21
|
+
doctor Check every page for drift and coverage gaps
|
|
22
|
+
--json print the machine-readable report instead
|
|
23
|
+
--ci exit 1 when any error was found (never on a warning alone)
|
|
24
|
+
explain <Page>
|
|
25
|
+
Print why a page's specimens are laid out the way they are
|
|
26
|
+
--heights <first>,<second> check side-by-side placement with these measured heights
|
|
27
|
+
--json print the machine-readable report instead
|
|
19
28
|
|
|
20
29
|
Options:
|
|
21
30
|
-h, --help Show this help
|
|
@@ -95,6 +104,31 @@ export async function runInit(projectRoot, rest, options = {}) {
|
|
|
95
104
|
? initNewProject(projectRoot, { kitRoot, installer: options.installer })
|
|
96
105
|
: initExistingProject(projectRoot, { yes: rest.includes('--yes'), confirm: options.confirm });
|
|
97
106
|
}
|
|
107
|
+
/** Pulled out of `main()` for the same reason `runInit` was in 0.1/0.2 (Fable's review): testable
|
|
108
|
+
* without spawning a process. `--ci`'s exit code is read from `runDoctor`'s own `exitCode` — a
|
|
109
|
+
* plain `doctor` (no `--ci`) always returns 0 here regardless of errors found. */
|
|
110
|
+
export function runDoctorCommand(projectRoot, rest) {
|
|
111
|
+
const config = resolveConfig(projectRoot);
|
|
112
|
+
const result = runDoctor(config);
|
|
113
|
+
const output = rest.includes('--json') ? JSON.stringify(toDoctorJson(result), null, 2) : formatHuman(result);
|
|
114
|
+
return { output, exitCode: rest.includes('--ci') ? result.exitCode : 0 };
|
|
115
|
+
}
|
|
116
|
+
/** Same "testable without spawning" shape as `runDoctorCommand` above. */
|
|
117
|
+
export function runExplainCommand(projectRoot, pageId, rest) {
|
|
118
|
+
if (!pageId)
|
|
119
|
+
return { output: 'Usage: ds-viewer explain <Page> [--heights <first>,<second>] [--json]', exitCode: 1 };
|
|
120
|
+
const heightsIndex = rest.indexOf('--heights');
|
|
121
|
+
const heightsRaw = heightsIndex !== -1 ? rest[heightsIndex + 1] : undefined;
|
|
122
|
+
const heights = heightsRaw ? parseHeights(heightsRaw) : undefined;
|
|
123
|
+
if (heightsRaw && !heights) {
|
|
124
|
+
return { output: `--heights must be two comma-separated numbers, e.g. --heights 420,610 (got "${heightsRaw}")`, exitCode: 1 };
|
|
125
|
+
}
|
|
126
|
+
const config = resolveConfig(projectRoot);
|
|
127
|
+
const result = explainPage(config, pageId, { heights });
|
|
128
|
+
if (!result)
|
|
129
|
+
return { output: `No page named "${pageId}" was found.`, exitCode: 1 };
|
|
130
|
+
return { output: rest.includes('--json') ? JSON.stringify(result, null, 2) : formatExplain(result), exitCode: 0 };
|
|
131
|
+
}
|
|
98
132
|
async function main() {
|
|
99
133
|
const [command, ...rest] = process.argv.slice(2);
|
|
100
134
|
const projectRoot = process.cwd();
|
|
@@ -122,6 +156,21 @@ async function main() {
|
|
|
122
156
|
console.log(`Synced ${result.componentCount} components, ${result.pageCount} pages.`);
|
|
123
157
|
return;
|
|
124
158
|
}
|
|
159
|
+
if (command === 'doctor') {
|
|
160
|
+
const { output, exitCode } = runDoctorCommand(projectRoot, rest);
|
|
161
|
+
console.log(output);
|
|
162
|
+
if (exitCode)
|
|
163
|
+
process.exitCode = exitCode;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (command === 'explain') {
|
|
167
|
+
const [pageId, ...explainRest] = rest;
|
|
168
|
+
const { output, exitCode } = runExplainCommand(projectRoot, pageId, explainRest);
|
|
169
|
+
console.log(output);
|
|
170
|
+
if (exitCode)
|
|
171
|
+
process.exitCode = exitCode;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
125
174
|
if (command === 'dev') {
|
|
126
175
|
const config = resolveConfig(projectRoot);
|
|
127
176
|
await dev(config);
|
package/dist/cli/pageIndex.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { resolveGlob } from "./glob.js";
|
|
4
4
|
/** Component pages: `<Export>.catalog.tsx` beside the component's entry file (design §2). A
|
|
@@ -10,6 +10,25 @@ export function discoverPages(input) {
|
|
|
10
10
|
const standalonePages = input.standalonePageGlobs.flatMap((pattern) => resolveGlob(input.projectRoot, pattern));
|
|
11
11
|
return [...componentPages, ...standalonePages].sort();
|
|
12
12
|
}
|
|
13
|
+
/** Every `*.catalog.tsx`/`.catalog.ts` file `doctor` should read — unlike `discoverPages`, this
|
|
14
|
+
* also finds a page beside a component whose folder still exists but whose export was renamed or
|
|
15
|
+
* removed (that page's file never changed, so it's still sitting right there). `componentFolders`
|
|
16
|
+
* is every distinct folder containing a currently-resolved component entry file — `cli/doctor.ts`
|
|
17
|
+
* derives it the same way `cli/sync.ts` does, by resolving `config.components` and taking each
|
|
18
|
+
* match's `path.dirname`. A folder that was deleted entirely (not just renamed within) has nothing
|
|
19
|
+
* left to find here, which is correct: there's no stale page file left on disk to warn about. */
|
|
20
|
+
export function discoverAllPageFiles(input) {
|
|
21
|
+
const componentPages = input.componentFolders.flatMap((folder) => {
|
|
22
|
+
try {
|
|
23
|
+
return readdirSync(folder).filter((name) => /\.catalog\.tsx?$/.test(name)).map((name) => path.join(folder, name));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
const standalonePages = input.standalonePageGlobs.flatMap((pattern) => resolveGlob(input.projectRoot, pattern));
|
|
30
|
+
return [...new Set([...componentPages, ...standalonePages])].sort();
|
|
31
|
+
}
|
|
13
32
|
/** Writes `.ds-viewer/generated/pages.ts`: imports every discovered page's default export,
|
|
14
33
|
* resolves each one's id (design §2 "Page id": `id ?? component ?? <file stem>` — a component
|
|
15
34
|
* page defaults to its export name, a standalone page to its own file's stem) and `file` (the
|
|
@@ -25,6 +44,13 @@ export function writePageIndex(generatedDir, pageFiles) {
|
|
|
25
44
|
writeFileSync(outFile, `${importLines.join('\n')}\n\n${resolveLines.join('\n')}\n\n${exportLine}\n`);
|
|
26
45
|
return outFile;
|
|
27
46
|
}
|
|
47
|
+
/** `page.id ?? page.component ?? <file's own stem>` (design §2 "Page id") — factored out of
|
|
48
|
+
* `writePageIndex`'s own inline logic above so `cli/doctor.ts` resolves a page's id exactly the
|
|
49
|
+
* same way, never a second, drifting copy of this rule. Takes the same two fields `StaticPage`
|
|
50
|
+
* (Task 3) and the generated page index both already carry. */
|
|
51
|
+
export function resolvePageId(page, file) {
|
|
52
|
+
return page.id ?? page.component ?? fileStem(file);
|
|
53
|
+
}
|
|
28
54
|
function toImportPath(fromDir, toFile) {
|
|
29
55
|
const withoutExt = toFile.replace(/\.tsx?$/, '');
|
|
30
56
|
const relativePath = path.relative(fromDir, withoutExt).split(path.sep).join('/');
|
package/dist/cli/props.js
CHANGED
|
@@ -11,15 +11,16 @@ const COMPONENT_TYPE_HINT = /MemoExoticComponent|ForwardRefExoticComponent|Named
|
|
|
11
11
|
* fixtures have no tsconfig of their own, so lookup reaches this repository's root
|
|
12
12
|
* `tsconfig.json` (Task 1); it declares no `paths`, `baseUrl`, or `jsx`, so `options.paths`
|
|
13
13
|
* alone decides resolution in tests. */
|
|
14
|
-
function readHostTsconfigOptions(entryFile) {
|
|
14
|
+
export function readHostTsconfigOptions(entryFile) {
|
|
15
15
|
const tsconfigPath = ts.findConfigFile(path.dirname(entryFile), ts.sys.fileExists);
|
|
16
16
|
if (!tsconfigPath)
|
|
17
|
-
return {};
|
|
17
|
+
return { options: {}, declarationFiles: [] };
|
|
18
18
|
const { config } = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
|
|
19
19
|
const parsed = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(tsconfigPath));
|
|
20
20
|
const { baseUrl, paths, jsx } = parsed.options;
|
|
21
21
|
const pathsBasePath = parsed.options.pathsBasePath;
|
|
22
|
-
|
|
22
|
+
const declarationFiles = parsed.fileNames.filter((file) => file.endsWith('.d.ts'));
|
|
23
|
+
return { baseUrl, paths, jsx, pathsBasePath, options: parsed.options, declarationFiles };
|
|
23
24
|
}
|
|
24
25
|
/** What is a component (design §3): an export whose type has a call signature returning
|
|
25
26
|
* JSX/ReactNode, or is typed as a Memo/ForwardRef/NamedExotic/ComponentType wrapper.
|