@krapwoo/ds-viewer 0.7.0 → 0.8.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 +35 -0
- package/dist/cli/explain.js +13 -8
- package/dist/cli/layout.js +267 -0
- package/dist/cli/main.js +126 -1
- package/dist/native/catalog/comparison.js +1 -1
- package/native/catalog/comparison.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,8 +63,43 @@ npm run ds-viewer dev # opens the catalog in a browser, re-syncing on every f
|
|
|
63
63
|
npm run ds-viewer sync # regenerates component/token/page data without starting a server
|
|
64
64
|
npm run ds-viewer doctor # checks every page for drift and coverage gaps (--json, --ci)
|
|
65
65
|
npm run ds-viewer explain <Page> # prints why a page's specimens are laid out the way they are
|
|
66
|
+
npm run ds-viewer explain --all # every page's geometry as JSON, to diff across versions
|
|
67
|
+
npm run ds-viewer layout-diff before.json after.json # what an upgrade changed about your layout
|
|
66
68
|
```
|
|
67
69
|
|
|
70
|
+
### Seeing what an upgrade changes
|
|
71
|
+
|
|
72
|
+
Layout rules live in this package and reach your catalog on `update`, while your page files never
|
|
73
|
+
change. So a new version can reshape a page with nothing in your repository to explain it. Snapshot
|
|
74
|
+
before and after, and the change becomes something you can read:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
npx ds-viewer explain --all > before.json # on the version you have
|
|
78
|
+
# ...upgrade...
|
|
79
|
+
npx ds-viewer explain --all > after.json
|
|
80
|
+
npx ds-viewer layout-diff before.json after.json
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
0.6.11 → 0.7.0, measured at 952px
|
|
85
|
+
|
|
86
|
+
Layout changes on 3 pages: Banner, EmptyState, Shimmer.
|
|
87
|
+
|
|
88
|
+
Banner
|
|
89
|
+
its two blocks can no longer share a row
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The snapshot holds what can be decided without a browser: each block's kind and minimum width;
|
|
93
|
+
list blocks add specimen size, item count, and column×row geometry at full and half width, while
|
|
94
|
+
token-section blocks add section count and their responsive column×row geometry. For a two-block
|
|
95
|
+
page, `sideEligible` records every gate known before measurement: both blocks must keep their minimum
|
|
96
|
+
width and both must still fit one row at half width. Eligibility is not the outcome — placement also
|
|
97
|
+
weighs a single tall specimen and the height actually saved, and neither is knowable without measuring
|
|
98
|
+
a real render.
|
|
99
|
+
|
|
100
|
+
Snapshots taken at different widths refuse to compare, since geometry at 952px and 1112px
|
|
101
|
+
legitimately differs and diffing them would report changes that never happened.
|
|
102
|
+
|
|
68
103
|
Only one `dev` serves a project at a time. A second one refuses, naming the port the first is on,
|
|
69
104
|
because both would generate into the same `.ds-viewer/` and overwrite each other's files — the
|
|
70
105
|
loser then serves a workspace that no longer describes it, which Expo reports as an unresolvable
|
package/dist/cli/explain.js
CHANGED
|
@@ -35,7 +35,7 @@ function cellOverrideNote(cells) {
|
|
|
35
35
|
* look at it; only the counts/keys/sizes they do look at are carried over. Every field Task 3
|
|
36
36
|
* reads is carried through here, including `previewWidths`/`fullWidthLabel`/`hide`/`itemsFill`/
|
|
37
37
|
* `maxColumns` — dropping any of them makes `explain` report different geometry than the viewer. */
|
|
38
|
-
function toSectionDef(page) {
|
|
38
|
+
export function toSectionDef(page) {
|
|
39
39
|
const comparison = page.comparison && {
|
|
40
40
|
rowLabel: page.comparison.rowLabel ?? '',
|
|
41
41
|
columnLabel: page.comparison.columnLabel ?? '',
|
|
@@ -125,12 +125,11 @@ function toPlacementBlock(block, height) {
|
|
|
125
125
|
? { ...base, itemCount: block.items.length, size: block.size, maxColumns: block.maxColumns }
|
|
126
126
|
: base;
|
|
127
127
|
}
|
|
128
|
-
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// page should never resolve here either.
|
|
128
|
+
/** Every page `cli/doctor.ts`'s own `discoverAllPageFiles` would find, with the same exclusions
|
|
129
|
+
* `runDoctor` applies (mirroring `sync`), so an excluded folder's page never resolves. Shared with
|
|
130
|
+
* `cli/layout.ts` rather than copied: a snapshot built from a different page set than `explain`
|
|
131
|
+
* reads would diff pages the viewer does not have. */
|
|
132
|
+
export function discoverStaticPages(config) {
|
|
134
133
|
const excluded = new Set((config.exclude ?? []).flatMap((pattern) => resolveGlob(config.projectRoot, pattern)));
|
|
135
134
|
const componentFolders = [
|
|
136
135
|
...new Set(config.components
|
|
@@ -139,7 +138,13 @@ export function explainPage(config, pageId, options = {}) {
|
|
|
139
138
|
.map((file) => path.dirname(file))),
|
|
140
139
|
];
|
|
141
140
|
const pageFiles = discoverAllPageFiles({ componentFolders, standalonePageGlobs: config.pages ?? [], projectRoot: config.projectRoot });
|
|
142
|
-
|
|
141
|
+
return readStaticPages(pageFiles);
|
|
142
|
+
}
|
|
143
|
+
/** Finds `pageId` among every page `cli/doctor.ts`'s own `discoverAllPageFiles` would find, builds
|
|
144
|
+
* its throwaway `SectionDef`, and asks the viewer's own pure layout functions for each block's
|
|
145
|
+
* decision — undefined when no page resolves to that id. */
|
|
146
|
+
export function explainPage(config, pageId, options = {}) {
|
|
147
|
+
const page = discoverStaticPages(config).find((p) => resolvePageId(p, p.file) === pageId);
|
|
143
148
|
if (!page)
|
|
144
149
|
return undefined;
|
|
145
150
|
// Important finding 4 (Fable's review): a page whose layout data isn't a literal (or that has a
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// cli/layout.ts — `explain --all` and `layout-diff`: capture every page's geometry as data,
|
|
2
|
+
// and report what a version changed.
|
|
3
|
+
//
|
|
4
|
+
// Layout rules live in this package and reach every consuming project on `update`, while their page
|
|
5
|
+
// files never change. So a release can reshape a catalog with nothing in the consumer's repository
|
|
6
|
+
// to explain it — 0.7.0 moved three pages from side-by-side to stacked, and the only trace was a
|
|
7
|
+
// changelog paragraph. This turns that into a measured artifact: snapshot before, snapshot after,
|
|
8
|
+
// diff the two.
|
|
9
|
+
import { resolvePageId } from "./pageIndex.js";
|
|
10
|
+
import { discoverStaticPages } from "./explain.js";
|
|
11
|
+
import { blockMinWidth, fitsOneRow, listGeometry, presentationBlocks, MATRIX_LAYOUT, } from "../native/catalog/comparison.js";
|
|
12
|
+
import { CATALOG_LAYOUT } from "../native/catalog/tokens.js";
|
|
13
|
+
import { sectionColumns } from "../native/catalog/tokenLayout.js";
|
|
14
|
+
import { toSectionDef } from "./explain.js";
|
|
15
|
+
function tokenSectionGeometry(block, width) {
|
|
16
|
+
const columns = sectionColumns(width, block.columns);
|
|
17
|
+
let rows = 0;
|
|
18
|
+
let used = 0;
|
|
19
|
+
let fillers = 0;
|
|
20
|
+
const closePartialRow = () => {
|
|
21
|
+
if (used === 0)
|
|
22
|
+
return;
|
|
23
|
+
rows += 1;
|
|
24
|
+
fillers += columns - used;
|
|
25
|
+
used = 0;
|
|
26
|
+
};
|
|
27
|
+
for (const section of block.sections) {
|
|
28
|
+
if (section.wide) {
|
|
29
|
+
closePartialRow();
|
|
30
|
+
rows += 1;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
used += 1;
|
|
34
|
+
if (used === columns)
|
|
35
|
+
closePartialRow();
|
|
36
|
+
}
|
|
37
|
+
closePartialRow();
|
|
38
|
+
return { columns, rows, fillers };
|
|
39
|
+
}
|
|
40
|
+
function geometryFor(block, width) {
|
|
41
|
+
if (block.kind === 'list') {
|
|
42
|
+
const { columns, rows, fillers } = listGeometry(block.items.length, width, block.size, block.maxColumns);
|
|
43
|
+
return { columns, rows, fillers };
|
|
44
|
+
}
|
|
45
|
+
if (block.kind === 'tokenSections')
|
|
46
|
+
return tokenSectionGeometry(block, width);
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
function snapshotMinWidth(block) {
|
|
50
|
+
const width = blockMinWidth(block);
|
|
51
|
+
return Number.isFinite(width) ? width : null;
|
|
52
|
+
}
|
|
53
|
+
/** One page's geometry, from the same static read `explain` uses. */
|
|
54
|
+
export function pageSnapshot(page, pageId) {
|
|
55
|
+
if (!page.checkable)
|
|
56
|
+
return { page: pageId, blocks: [], notCheckable: true };
|
|
57
|
+
const blocks = presentationBlocks(toSectionDef(page), { defaultPreviewWidths: [402] });
|
|
58
|
+
const full = MATRIX_LAYOUT.laptopContentWidth;
|
|
59
|
+
const half = Math.floor((full - CATALOG_LAYOUT.blockGap) / 2);
|
|
60
|
+
const snapshot = {
|
|
61
|
+
page: pageId,
|
|
62
|
+
blocks: blocks.map((block) => {
|
|
63
|
+
const fullGeometry = geometryFor(block, full);
|
|
64
|
+
const halfGeometry = geometryFor(block, half);
|
|
65
|
+
return {
|
|
66
|
+
title: block.kind === 'tokenSections' ? 'Token sections' : block.title,
|
|
67
|
+
kind: block.kind,
|
|
68
|
+
...(block.kind === 'list' ? { size: block.size, items: block.items.length } : {}),
|
|
69
|
+
...(block.kind === 'tokenSections' ? { items: block.sections.length } : {}),
|
|
70
|
+
minWidth: snapshotMinWidth(block),
|
|
71
|
+
...(fullGeometry ? { full: fullGeometry } : {}),
|
|
72
|
+
...(halfGeometry ? { half: halfGeometry } : {}),
|
|
73
|
+
};
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
if (blocks.length === 2) {
|
|
77
|
+
const [first, second] = blocks;
|
|
78
|
+
const firstMin = blockMinWidth(first);
|
|
79
|
+
const secondMin = blockMinWidth(second);
|
|
80
|
+
const asPlacement = (b) => ({
|
|
81
|
+
kind: b.kind, height: 0, minWidth: blockMinWidth(b),
|
|
82
|
+
...(b.kind === 'list' ? { itemCount: b.items.length, size: b.size, maxColumns: b.maxColumns } : {}),
|
|
83
|
+
});
|
|
84
|
+
// This is every static gate that runs before measured height enters choosePlacement. Omitting
|
|
85
|
+
// the min-width check made Avatar claim eligibility even though its 600px grid can never fit the
|
|
86
|
+
// 462px half-row; a later fitsOneRow change would then fabricate a placement change for it.
|
|
87
|
+
snapshot.sideEligible = half > 0 && firstMin <= half && secondMin <= half
|
|
88
|
+
&& fitsOneRow(asPlacement(first), half) && fitsOneRow(asPlacement(second), half);
|
|
89
|
+
}
|
|
90
|
+
return snapshot;
|
|
91
|
+
}
|
|
92
|
+
/** Every page's geometry, ordered by page id so two snapshots diff line for line. */
|
|
93
|
+
export function layoutSnapshot(config, version) {
|
|
94
|
+
const staticPages = discoverStaticPages(config);
|
|
95
|
+
const ids = staticPages.map((page) => resolvePageId(page, page.file));
|
|
96
|
+
const duplicates = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))];
|
|
97
|
+
if (duplicates.length > 0) {
|
|
98
|
+
throw new Error(`Cannot snapshot duplicate page id${duplicates.length === 1 ? '' : 's'}: ${duplicates.join(', ')}. Run ds-viewer doctor and give each page a unique id.`);
|
|
99
|
+
}
|
|
100
|
+
const pages = staticPages
|
|
101
|
+
.map((page, index) => pageSnapshot(page, ids[index]))
|
|
102
|
+
// Code-point order is stable across machines; localeCompare can produce different snapshot text
|
|
103
|
+
// under different host locales, defeating a committed artifact's textual review.
|
|
104
|
+
.sort((a, b) => (a.page < b.page ? -1 : a.page > b.page ? 1 : 0));
|
|
105
|
+
return { version, width: MATRIX_LAYOUT.laptopContentWidth, pages };
|
|
106
|
+
}
|
|
107
|
+
function describeGeometry(g) {
|
|
108
|
+
return g ? `${g.columns}×${g.rows}` : '—';
|
|
109
|
+
}
|
|
110
|
+
function describeMinWidth(width) {
|
|
111
|
+
return width === null ? 'a full row' : `${width}px`;
|
|
112
|
+
}
|
|
113
|
+
function blockKeys(blocks) {
|
|
114
|
+
const seen = new Map();
|
|
115
|
+
const totals = new Map();
|
|
116
|
+
for (const block of blocks)
|
|
117
|
+
totals.set(block.title, (totals.get(block.title) ?? 0) + 1);
|
|
118
|
+
return blocks.map((block) => {
|
|
119
|
+
const occurrence = (seen.get(block.title) ?? 0) + 1;
|
|
120
|
+
seen.set(block.title, occurrence);
|
|
121
|
+
return {
|
|
122
|
+
key: `${block.title}\u0000${occurrence}`,
|
|
123
|
+
label: (totals.get(block.title) ?? 0) > 1 ? `${block.title} (${occurrence})` : block.title,
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** What changed between two snapshots. Pure, so it is the part worth testing.
|
|
128
|
+
*
|
|
129
|
+
* Blocks are matched by title, not by position. Comparing by index means a block inserted at the
|
|
130
|
+
* front diffs every later block against its neighbour: a page that gained an intro block reported
|
|
131
|
+
* four changes, including a brand-new block "changing" from a geometry it never had and an
|
|
132
|
+
* untouched block changing to its neighbour's. A tool whose output is meant to be believed cannot
|
|
133
|
+
* invent changes that did not happen. */
|
|
134
|
+
export function diffLayouts(before, after) {
|
|
135
|
+
const changes = [];
|
|
136
|
+
const beforePages = new Map(before.pages.map((p) => [p.page, p]));
|
|
137
|
+
const afterPages = new Map(after.pages.map((p) => [p.page, p]));
|
|
138
|
+
for (const page of before.pages) {
|
|
139
|
+
if (!afterPages.has(page.page))
|
|
140
|
+
changes.push({ page: page.page, kind: 'removed', detail: 'the page is gone' });
|
|
141
|
+
}
|
|
142
|
+
for (const page of after.pages) {
|
|
143
|
+
const was = beforePages.get(page.page);
|
|
144
|
+
if (!was) {
|
|
145
|
+
changes.push({ page: page.page, kind: 'added', detail: 'a new page' });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (Boolean(was.notCheckable) !== Boolean(page.notCheckable)) {
|
|
149
|
+
changes.push({
|
|
150
|
+
page: page.page, kind: 'checkable',
|
|
151
|
+
detail: page.notCheckable ? 'its layout is no longer statically readable' : 'its layout became statically readable',
|
|
152
|
+
});
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// Title is a block's identity here. Two blocks on one page sharing a title would be
|
|
156
|
+
// indistinguishable, so those fall back to their position *within that title* — which keeps a
|
|
157
|
+
// page with two "Variants" blocks comparing like for like instead of silently dropping one.
|
|
158
|
+
const index = (blocks) => {
|
|
159
|
+
const map = new Map();
|
|
160
|
+
for (const block of blocks) {
|
|
161
|
+
const list = map.get(block.title) ?? [];
|
|
162
|
+
list.push(block);
|
|
163
|
+
map.set(block.title, list);
|
|
164
|
+
}
|
|
165
|
+
return map;
|
|
166
|
+
};
|
|
167
|
+
const oldBlocks = index(was.blocks);
|
|
168
|
+
const newBlocks = index(page.blocks);
|
|
169
|
+
for (const [title, list] of oldBlocks) {
|
|
170
|
+
const now = newBlocks.get(title) ?? [];
|
|
171
|
+
for (let i = now.length; i < list.length; i += 1) {
|
|
172
|
+
changes.push({ page: page.page, kind: 'blocks', detail: `"${title}" is gone` });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const [title, list] of newBlocks) {
|
|
176
|
+
const before_ = oldBlocks.get(title) ?? [];
|
|
177
|
+
for (const [i, block] of list.entries()) {
|
|
178
|
+
const old = before_[i];
|
|
179
|
+
if (!old) {
|
|
180
|
+
changes.push({ page: page.page, kind: 'blocks', detail: `"${title}" is new` });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (old.kind !== block.kind) {
|
|
184
|
+
changes.push({ page: page.page, kind: 'blocks', detail: `"${block.title}" is a ${block.kind} block, was ${old.kind}` });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (old.minWidth !== block.minWidth) {
|
|
188
|
+
changes.push({
|
|
189
|
+
page: page.page,
|
|
190
|
+
kind: 'geometry',
|
|
191
|
+
detail: `"${block.title}" needs ${describeMinWidth(block.minWidth)}, was ${describeMinWidth(old.minWidth)}`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
for (const width of ['full', 'half']) {
|
|
195
|
+
const a = old[width];
|
|
196
|
+
const b = block[width];
|
|
197
|
+
if (describeGeometry(a) !== describeGeometry(b)) {
|
|
198
|
+
changes.push({
|
|
199
|
+
page: page.page, kind: 'geometry',
|
|
200
|
+
detail: `"${block.title}" at ${width} width: ${describeGeometry(a)} → ${describeGeometry(b)}`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Compare relative order among blocks that exist on both sides. Inserting a new block at the
|
|
207
|
+
// front does not falsely say every existing block moved, but swapping two existing blocks does
|
|
208
|
+
// remain visible rather than disappearing behind title-based identity.
|
|
209
|
+
const oldKeyed = blockKeys(was.blocks);
|
|
210
|
+
const newKeyed = blockKeys(page.blocks);
|
|
211
|
+
const oldSet = new Set(oldKeyed.map(({ key }) => key));
|
|
212
|
+
const newSet = new Set(newKeyed.map(({ key }) => key));
|
|
213
|
+
const oldCommon = oldKeyed.filter(({ key }) => newSet.has(key));
|
|
214
|
+
const newCommon = newKeyed.filter(({ key }) => oldSet.has(key));
|
|
215
|
+
const oldPositions = new Map(oldCommon.map(({ key }, index) => [key, index]));
|
|
216
|
+
for (const [index, { key, label }] of newCommon.entries()) {
|
|
217
|
+
const oldIndex = oldPositions.get(key);
|
|
218
|
+
if (oldIndex !== undefined && oldIndex !== index) {
|
|
219
|
+
changes.push({
|
|
220
|
+
page: page.page,
|
|
221
|
+
kind: 'blocks',
|
|
222
|
+
detail: `"${label}" moved from block ${oldIndex + 1} to ${index + 1}`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (typeof was.sideEligible === 'boolean' && typeof page.sideEligible === 'boolean' && was.sideEligible !== page.sideEligible) {
|
|
227
|
+
changes.push({
|
|
228
|
+
page: page.page, kind: 'placement',
|
|
229
|
+
detail: page.sideEligible
|
|
230
|
+
? 'its two blocks can now share a row'
|
|
231
|
+
: 'its two blocks can no longer share a row',
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return changes;
|
|
236
|
+
}
|
|
237
|
+
/** True when two snapshots were measured at the same width, and so can be compared at all.
|
|
238
|
+
* Geometry at 952px and at 1112px legitimately differs; diffing across them reports rule changes
|
|
239
|
+
* that never happened. Exported because every caller needs the guard, not just the one that
|
|
240
|
+
* prints prose. */
|
|
241
|
+
export function comparableWidths(before, after) {
|
|
242
|
+
return before.width === after.width;
|
|
243
|
+
}
|
|
244
|
+
/** Human output. Silence is the useful answer, so it says so explicitly rather than printing
|
|
245
|
+
* nothing and leaving the reader to wonder whether it ran. */
|
|
246
|
+
export function formatLayoutDiff(before, after, changes) {
|
|
247
|
+
const header = `${before.version} → ${after.version}, measured at ${after.width}px`;
|
|
248
|
+
if (before.width !== after.width) {
|
|
249
|
+
return `${header}\n\nThese snapshots were taken at different widths (${before.width}px and ${after.width}px), so their geometry is not comparable.`;
|
|
250
|
+
}
|
|
251
|
+
if (changes.length === 0)
|
|
252
|
+
return `${header}\n\nNo page changes layout.`;
|
|
253
|
+
const byPage = new Map();
|
|
254
|
+
for (const change of changes) {
|
|
255
|
+
const list = byPage.get(change.page) ?? [];
|
|
256
|
+
list.push(change);
|
|
257
|
+
byPage.set(change.page, list);
|
|
258
|
+
}
|
|
259
|
+
const pages = [...byPage.keys()];
|
|
260
|
+
const lines = [header, '', `Layout changes on ${pages.length} page${pages.length === 1 ? '' : 's'}: ${pages.join(', ')}.`, ''];
|
|
261
|
+
for (const [page, list] of byPage) {
|
|
262
|
+
lines.push(`${page}`);
|
|
263
|
+
for (const change of list)
|
|
264
|
+
lines.push(` ${change.detail}`);
|
|
265
|
+
}
|
|
266
|
+
return lines.join('\n');
|
|
267
|
+
}
|
package/dist/cli/main.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { resolveConfig } from "./config.js";
|
|
5
6
|
import { initExistingProject, initNewProject, promptInitMode } from "./init.js";
|
|
@@ -9,6 +10,7 @@ import { formatHuman, runDoctor, toDoctorJson, withExtraIssues } from "./doctor.
|
|
|
9
10
|
import { renderCheck } from "./render.js";
|
|
10
11
|
import { RENDER_WORKSPACE_DIR } from "./types.js";
|
|
11
12
|
import { explainPage, formatExplain, parseHeights } from "./explain.js";
|
|
13
|
+
import { comparableWidths, diffLayouts, formatLayoutDiff, layoutSnapshot } from "./layout.js";
|
|
12
14
|
import { readOwnVersion } from "./packageVersion.js";
|
|
13
15
|
import { checkForUpdate, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck } from "./updateCheck.js";
|
|
14
16
|
import { runKitDiffCommand } from "./kitDiff.js";
|
|
@@ -40,6 +42,11 @@ Commands:
|
|
|
40
42
|
Print why a page's specimens are laid out the way they are
|
|
41
43
|
--heights <first>,<second> check side-by-side placement with these measured heights
|
|
42
44
|
--json print the machine-readable report instead
|
|
45
|
+
--all every page's geometry as JSON, for layout-diff
|
|
46
|
+
layout-diff <before.json> <after.json>
|
|
47
|
+
What changed about every page's layout between two explain --all snapshots, so a
|
|
48
|
+
viewer upgrade that reshapes your catalog is visible rather than silent
|
|
49
|
+
--json print the changes as JSON
|
|
43
50
|
kit diff <Component>
|
|
44
51
|
Show the user's kit file(s) for <Component> against the installed kit's copy
|
|
45
52
|
migrate --from <version>
|
|
@@ -171,8 +178,21 @@ export async function runDoctorCommand(projectRoot, rest, options = {}) {
|
|
|
171
178
|
}
|
|
172
179
|
/** Same "testable without spawning" shape as `runDoctorCommand` above. */
|
|
173
180
|
export function runExplainCommand(projectRoot, pageId, rest) {
|
|
181
|
+
// `--all`: every page's geometry as one JSON document, for `layout-diff` to compare across
|
|
182
|
+
// versions. It takes no page id, and it is JSON only — the whole point is a machine-readable
|
|
183
|
+
// artifact, and a prose dump of 50 pages would be read by nobody.
|
|
184
|
+
if (pageId === '--all' || rest.includes('--all')) {
|
|
185
|
+
const config = resolveConfig(projectRoot);
|
|
186
|
+
const version = readOwnVersion(path.dirname(fileURLToPath(import.meta.url)));
|
|
187
|
+
try {
|
|
188
|
+
return { output: JSON.stringify(layoutSnapshot(config, version), null, 2), exitCode: 0 };
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
return { output: error.message, exitCode: 1 };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
174
194
|
if (!pageId)
|
|
175
|
-
return { output: 'Usage: ds-viewer explain <Page> [--heights <first>,<second>] [--json]', exitCode: 1 };
|
|
195
|
+
return { output: 'Usage: ds-viewer explain <Page> [--heights <first>,<second>] [--json], or explain --all for every page as JSON', exitCode: 1 };
|
|
176
196
|
const heightsIndex = rest.indexOf('--heights');
|
|
177
197
|
const heightsRaw = heightsIndex !== -1 ? rest[heightsIndex + 1] : undefined;
|
|
178
198
|
const heights = heightsRaw ? parseHeights(heightsRaw) : undefined;
|
|
@@ -185,6 +205,104 @@ export function runExplainCommand(projectRoot, pageId, rest) {
|
|
|
185
205
|
return { output: `No page named "${pageId}" was found.`, exitCode: 1 };
|
|
186
206
|
return { output: rest.includes('--json') ? JSON.stringify(result, null, 2) : formatExplain(result), exitCode: 0 };
|
|
187
207
|
}
|
|
208
|
+
function isFiniteNumber(value) {
|
|
209
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
210
|
+
}
|
|
211
|
+
function isGeometry(value) {
|
|
212
|
+
if (!value || typeof value !== 'object')
|
|
213
|
+
return false;
|
|
214
|
+
const geometry = value;
|
|
215
|
+
return Number.isInteger(geometry.columns) && geometry.columns >= 0
|
|
216
|
+
&& Number.isInteger(geometry.rows) && geometry.rows >= 0
|
|
217
|
+
&& Number.isInteger(geometry.fillers) && geometry.fillers >= 0;
|
|
218
|
+
}
|
|
219
|
+
function isLayoutSnapshot(value) {
|
|
220
|
+
if (!value || typeof value !== 'object')
|
|
221
|
+
return false;
|
|
222
|
+
const snapshot = value;
|
|
223
|
+
if (typeof snapshot.version !== 'string' || !isFiniteNumber(snapshot.width) || snapshot.width <= 0 || !Array.isArray(snapshot.pages))
|
|
224
|
+
return false;
|
|
225
|
+
const ids = new Set();
|
|
226
|
+
for (const entry of snapshot.pages) {
|
|
227
|
+
if (!entry || typeof entry !== 'object')
|
|
228
|
+
return false;
|
|
229
|
+
const page = entry;
|
|
230
|
+
if (typeof page.page !== 'string' || page.page.length === 0 || ids.has(page.page) || !Array.isArray(page.blocks))
|
|
231
|
+
return false;
|
|
232
|
+
ids.add(page.page);
|
|
233
|
+
if (page.sideEligible !== undefined && typeof page.sideEligible !== 'boolean')
|
|
234
|
+
return false;
|
|
235
|
+
if (page.notCheckable !== undefined && page.notCheckable !== true)
|
|
236
|
+
return false;
|
|
237
|
+
for (const entryBlock of page.blocks) {
|
|
238
|
+
if (!entryBlock || typeof entryBlock !== 'object')
|
|
239
|
+
return false;
|
|
240
|
+
const block = entryBlock;
|
|
241
|
+
if (typeof block.title !== 'string' || !['grid', 'grouped', 'list', 'preview', 'tokenSections', 'empty'].includes(String(block.kind)))
|
|
242
|
+
return false;
|
|
243
|
+
if (!(block.minWidth === null || isFiniteNumber(block.minWidth)))
|
|
244
|
+
return false;
|
|
245
|
+
if (block.items !== undefined && (!Number.isInteger(block.items) || block.items < 0))
|
|
246
|
+
return false;
|
|
247
|
+
if (block.size !== undefined && typeof block.size !== 'string')
|
|
248
|
+
return false;
|
|
249
|
+
if (block.full !== undefined && !isGeometry(block.full))
|
|
250
|
+
return false;
|
|
251
|
+
if (block.half !== undefined && !isGeometry(block.half))
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
/** `layout-diff <before.json> <after.json>`: what a version changed about every page's geometry. */
|
|
258
|
+
export function runLayoutDiffCommand(args, deps = {}) {
|
|
259
|
+
const [beforePath, afterPath] = args.filter((a) => !a.startsWith('--'));
|
|
260
|
+
if (!beforePath || !afterPath) {
|
|
261
|
+
return { output: 'Usage: ds-viewer layout-diff <before.json> <after.json>\n\nBoth are written by `ds-viewer explain --all`.', exitCode: 1 };
|
|
262
|
+
}
|
|
263
|
+
const read = deps.readFile ?? ((file) => readFileSync(file, 'utf8'));
|
|
264
|
+
const load = (file) => {
|
|
265
|
+
let raw;
|
|
266
|
+
try {
|
|
267
|
+
raw = read(file);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return `Could not read ${file}.`;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
const parsed = JSON.parse(raw);
|
|
274
|
+
// Refuse plausible-but-incomplete JSON before the diff touches it. Treating an absent block
|
|
275
|
+
// array as empty reports invented removals; dereferencing it reports a raw TypeError instead
|
|
276
|
+
// of telling the user they selected the wrong file.
|
|
277
|
+
if (!isLayoutSnapshot(parsed)) {
|
|
278
|
+
return `${file} is not a layout snapshot. Write one with \`ds-viewer explain --all\`.`;
|
|
279
|
+
}
|
|
280
|
+
return parsed;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return `${file} is not valid JSON.`;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
const before = load(beforePath);
|
|
287
|
+
if (typeof before === 'string')
|
|
288
|
+
return { output: before, exitCode: 1 };
|
|
289
|
+
const after = load(afterPath);
|
|
290
|
+
if (typeof after === 'string')
|
|
291
|
+
return { output: after, exitCode: 1 };
|
|
292
|
+
// Before diffing, not only before printing: the guard used to live in `formatLayoutDiff`, so
|
|
293
|
+
// `--json` — the path most likely to be automated in CI — returned a confident list of changes
|
|
294
|
+
// computed across incomparable widths.
|
|
295
|
+
if (!comparableWidths(before, after)) {
|
|
296
|
+
return {
|
|
297
|
+
output: `These snapshots were taken at different widths (${before.width}px and ${after.width}px), so their geometry is not comparable. The two viewer versions use different catalog content widths; compare releases whose snapshot widths match.`,
|
|
298
|
+
exitCode: 1,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const changes = diffLayouts(before, after);
|
|
302
|
+
if (args.includes('--json'))
|
|
303
|
+
return { output: JSON.stringify(changes, null, 2), exitCode: 0 };
|
|
304
|
+
return { output: formatLayoutDiff(before, after, changes), exitCode: 0 };
|
|
305
|
+
}
|
|
188
306
|
async function main() {
|
|
189
307
|
const [command, ...rest] = process.argv.slice(2);
|
|
190
308
|
const projectRoot = process.cwd();
|
|
@@ -219,6 +337,13 @@ async function main() {
|
|
|
219
337
|
process.exitCode = exitCode;
|
|
220
338
|
return;
|
|
221
339
|
}
|
|
340
|
+
if (command === 'layout-diff') {
|
|
341
|
+
const { output, exitCode } = runLayoutDiffCommand(rest);
|
|
342
|
+
console.log(output);
|
|
343
|
+
if (exitCode)
|
|
344
|
+
process.exitCode = exitCode;
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
222
347
|
if (command === 'explain') {
|
|
223
348
|
const [pageId, ...explainRest] = rest;
|
|
224
349
|
const { output, exitCode } = runExplainCommand(projectRoot, pageId, explainRest);
|
|
@@ -341,7 +341,7 @@ function heightAtWidth(block, width, available) {
|
|
|
341
341
|
* same number of rows as a full row would give": a column cap can already force a block to wrap
|
|
342
342
|
* at full width, and two wrapped rows staying two wrapped rows is not a reason to halve every
|
|
343
343
|
* specimen on the page. One row is the case where sharing genuinely costs nothing. */
|
|
344
|
-
function fitsOneRow(block, half) {
|
|
344
|
+
export function fitsOneRow(block, half) {
|
|
345
345
|
if (block.kind !== 'list' || !block.itemCount || !block.size)
|
|
346
346
|
return true;
|
|
347
347
|
return listGeometry(block.itemCount, half, block.size, block.maxColumns).rows <= 1;
|
|
@@ -441,7 +441,7 @@ function heightAtWidth(block: PlacementBlock, width: number, available: number):
|
|
|
441
441
|
* same number of rows as a full row would give": a column cap can already force a block to wrap
|
|
442
442
|
* at full width, and two wrapped rows staying two wrapped rows is not a reason to halve every
|
|
443
443
|
* specimen on the page. One row is the case where sharing genuinely costs nothing. */
|
|
444
|
-
function fitsOneRow(block: PlacementBlock, half: number): boolean {
|
|
444
|
+
export function fitsOneRow(block: PlacementBlock, half: number): boolean {
|
|
445
445
|
if (block.kind !== 'list' || !block.itemCount || !block.size) return true;
|
|
446
446
|
return listGeometry(block.itemCount, half, block.size, block.maxColumns).rows <= 1;
|
|
447
447
|
}
|