@krapwoo/ds-viewer 0.4.4 → 0.4.6
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 +20 -10
- package/dist/cli/dev.js +31 -6
- package/dist/cli/endpoint.js +76 -1
- package/dist/cli/main.js +4 -2
- package/dist/cli/updateCheck.js +174 -43
- package/dist/cli/workspace.js +2 -1
- package/dist/native/catalog/catalogNavigation.js +11 -0
- package/dist/native/catalog/tokens.js +2 -0
- package/native/catalog/CatalogShell.tsx +47 -12
- package/native/catalog/CatalogSidebar.tsx +8 -7
- package/native/catalog/ComparisonGrid.tsx +11 -11
- package/native/catalog/ComparisonGroups.tsx +6 -8
- package/native/catalog/ComparisonList.tsx +6 -8
- package/native/catalog/SectionBlock.tsx +9 -6
- package/native/catalog/SpecimenSurface.tsx +64 -26
- package/native/catalog/UpdatePanel.tsx +161 -5
- package/native/catalog/catalogNavigation.ts +14 -1
- package/native/catalog/specimenFill.ts +102 -0
- package/native/catalog/specimenSurfaceStyle.ts +9 -3
- package/native/catalog/tokens.ts +2 -0
- package/native/catalog/types.ts +27 -9
- package/native/catalog/updatePanelState.ts +72 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -190,13 +190,15 @@ actually apply:
|
|
|
190
190
|
within what fits a 1280px laptop: 5 compact, 3 regular, or 2 wide columns. `states` items whose
|
|
191
191
|
key matches a row or column key are not repeated below the grid.
|
|
192
192
|
- `specimenSize: 'compact' | 'regular' | 'wide'` — the page's specimen width class.
|
|
193
|
-
- `specimenSurface: 'neutral' | 'white' | 'dark' | 'transparent'` — the
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
white
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
193
|
+
- `specimenSurface: 'auto' | 'neutral' | 'white' | 'dark' | 'transparent'` — the fill of each
|
|
194
|
+
example's cell (list, grouped and grid cells, and a non-token `render()` preview). Defaults to
|
|
195
|
+
`'auto'`: after the page draws, a cell turns light gray only when its own example would vanish
|
|
196
|
+
against white (its largest coloured box is near-white with no visible border, like a white card,
|
|
197
|
+
sheet or neutral banner; a shadow alone doesn't count). Neighbouring examples keep a white cell,
|
|
198
|
+
and most pages need nothing. Override it when the guess is wrong: `'neutral'` makes every example
|
|
199
|
+
cell gray, `'white'` or `'transparent'` keeps them all white, and `'dark'` checks a light
|
|
200
|
+
component on a dark fill. Detection runs in the browser viewer; elsewhere cells stay white.
|
|
201
|
+
Token galleries and sections are never filled.
|
|
200
202
|
- `variants`/`states`' own `maxColumns: 1 | 2 | 3 | 4 | 5` — caps how many columns that slot's list
|
|
201
203
|
ever wraps into (e.g. a 3-item size scale that should always read as one row of exactly 3, never
|
|
202
204
|
more just because a laptop is wide). A narrow window still drops below the cap when it has to —
|
|
@@ -398,9 +400,17 @@ markers or its own file.
|
|
|
398
400
|
|
|
399
401
|
## Updating
|
|
400
402
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
403
|
+
The sidebar footer always shows the installed version ("DS Viewer 0.4.5") and opens the update
|
|
404
|
+
page (`#ds-viewer-update`). With no newer version known, that page says which situation you're in —
|
|
405
|
+
on the latest version, automatic checks off, or npm unreachable — and offers **Check now** (asks npm
|
|
406
|
+
right away, even with automatic checks off) and a **Check for updates automatically** switch. The
|
|
407
|
+
switch saves your own choice for this project on this computer (in your user settings folder, not in
|
|
408
|
+
the project), overriding the project's `updateCheck` for you only; `DS_VIEWER_NO_UPDATE_CHECK=1`
|
|
409
|
+
(CI) still wins over both. `doctor` follows the same setting.
|
|
410
|
+
|
|
411
|
+
When a new version is published, the footer turns into "Update available · 0.5.0" — a banner too,
|
|
412
|
+
for a major version, dismissible per version. Either opens the same page, which then shows what would
|
|
413
|
+
change and one **Update now** button:
|
|
404
414
|
|
|
405
415
|
```
|
|
406
416
|
0.4.0 → 0.5.0 Minor
|
package/dist/cli/dev.js
CHANGED
|
@@ -9,7 +9,7 @@ import { sync } from "./sync.js";
|
|
|
9
9
|
import { writeWorkspace } from "./workspace.js";
|
|
10
10
|
import { findFreePort } from "./port.js";
|
|
11
11
|
import { globBaseFolder } from "./glob.js";
|
|
12
|
-
import {
|
|
12
|
+
import { buildVersionStatus, createVersionController, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck, runUpdateCheck, statusFromCheck, writeUpdateFile, writeVersionFile, } from "./updateCheck.js";
|
|
13
13
|
import { readOwnVersion } from "./packageVersion.js";
|
|
14
14
|
import { createUpdateEndpoint } from "./endpoint.js";
|
|
15
15
|
import { buildUpdatePlan } from "./updatePlan.js";
|
|
@@ -70,10 +70,20 @@ export function reloadWorkspace(projectRoot, updateEndpoint) {
|
|
|
70
70
|
* the same way `reloadWorkspace` already is, so this plan never needs to test `dev()` itself
|
|
71
71
|
* (which spawns a real Expo process) to prove this one behavior. */
|
|
72
72
|
export async function refreshUpdateFile(config, ownVersion, options = {}) {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
const env = options.env ?? process.env;
|
|
74
|
+
const personal = readPersonalAutoCheck(options.prefsDir ?? defaultPreferencesDir(), config.projectRoot);
|
|
75
|
+
const autoCheck = resolveAutoCheck(config, env, personal);
|
|
76
|
+
// An injected `checkForUpdate` (tests) stands in for the real check; it can't report "couldn't
|
|
77
|
+
// reach npm", so it always counts as answered.
|
|
78
|
+
const injected = options.checkForUpdate;
|
|
79
|
+
const status = injected
|
|
80
|
+
? autoCheck.enabled
|
|
81
|
+
? statusFromCheck(ownVersion, autoCheck, { result: await injected(ownVersion), reached: true })
|
|
82
|
+
: { current: ownVersion, autoCheck, lastOutcome: 'not-run', update: null }
|
|
83
|
+
: await buildVersionStatus(ownVersion, autoCheck);
|
|
84
|
+
writeUpdateFile(config.projectRoot, status.update ? { ...status.update, checkedAt: status.lastCheckedAt ?? new Date().toISOString() } : undefined);
|
|
85
|
+
writeVersionFile(config.projectRoot, status);
|
|
86
|
+
return status;
|
|
77
87
|
}
|
|
78
88
|
// Promisified, not `execFileSync` — install/migrate/doctor each take real seconds, and this now
|
|
79
89
|
// runs from inside the endpoint's own request-handling process, which must keep answering
|
|
@@ -284,8 +294,19 @@ export async function performRestart(handoff, deps = {}) {
|
|
|
284
294
|
export async function dev(initialConfig, devOptions = {}) {
|
|
285
295
|
assertLocalInstall(initialConfig.projectRoot);
|
|
286
296
|
const ownVersion = readOwnVersion(import.meta.dirname);
|
|
287
|
-
await refreshUpdateFile(initialConfig, ownVersion);
|
|
288
297
|
let config = initialConfig;
|
|
298
|
+
const version = createVersionController({
|
|
299
|
+
ownVersion,
|
|
300
|
+
projectRoot: initialConfig.projectRoot,
|
|
301
|
+
prefsDir: defaultPreferencesDir(),
|
|
302
|
+
env: process.env,
|
|
303
|
+
initial: await refreshUpdateFile(initialConfig, ownVersion),
|
|
304
|
+
getConfig: () => config,
|
|
305
|
+
// **Check now** always asks npm, even with automatic checks off: you asked for this one check.
|
|
306
|
+
// The result lives only in this process; `update.json` and `version.json` are rewritten at
|
|
307
|
+
// the next start (rewriting them now would reload the page).
|
|
308
|
+
runCheck: () => runUpdateCheck(ownVersion, { force: true, timeoutMs: 5000 }),
|
|
309
|
+
});
|
|
289
310
|
sync(config);
|
|
290
311
|
// Restart handoff step 2/4: a status file left by the *previous* `dev` process (right before it
|
|
291
312
|
// exited to restart) is read once, then deleted — a plain refresh afterward shows "idle"/"no
|
|
@@ -336,6 +357,9 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
336
357
|
});
|
|
337
358
|
},
|
|
338
359
|
getStatus: () => updateStatus,
|
|
360
|
+
getVersionStatus: version.getVersionStatus,
|
|
361
|
+
checkNow: version.checkNow,
|
|
362
|
+
setAutoCheck: version.setAutoCheck,
|
|
339
363
|
};
|
|
340
364
|
const endpointServer = createUpdateEndpoint(endpointDeps);
|
|
341
365
|
await new Promise((resolve) => endpointServer.listen(0, '127.0.0.1', resolve));
|
|
@@ -374,6 +398,7 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
374
398
|
configDebounceTimer = setTimeout(() => {
|
|
375
399
|
try {
|
|
376
400
|
config = reloadWorkspace(config.projectRoot, updateEndpoint);
|
|
401
|
+
version.refreshAutoCheck();
|
|
377
402
|
}
|
|
378
403
|
catch (error) {
|
|
379
404
|
console.warn(`Could not reload ds-viewer.config.ts after a change: ${error.message}`);
|
package/dist/cli/endpoint.js
CHANGED
|
@@ -14,6 +14,38 @@ function secretMatches(received, expected) {
|
|
|
14
14
|
const expectedBuf = Buffer.from(expected);
|
|
15
15
|
return receivedBuf.length === expectedBuf.length && timingSafeEqual(receivedBuf, expectedBuf);
|
|
16
16
|
}
|
|
17
|
+
/** Reads a small JSON request body (the switch's `{ enabled }`); anything over 1 KB is refused. */
|
|
18
|
+
function readJsonBody(req) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
let body = '';
|
|
21
|
+
let tooLarge = false;
|
|
22
|
+
req.setEncoding('utf8');
|
|
23
|
+
// Keep draining past the cap (without storing it) so the handler can still answer 400 —
|
|
24
|
+
// destroying the socket would drop that answer.
|
|
25
|
+
req.on('data', (chunk) => {
|
|
26
|
+
if (tooLarge)
|
|
27
|
+
return;
|
|
28
|
+
body += chunk;
|
|
29
|
+
if (body.length > 1024) {
|
|
30
|
+
tooLarge = true;
|
|
31
|
+
body = '';
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
req.on('end', () => {
|
|
35
|
+
if (tooLarge) {
|
|
36
|
+
reject(new Error('Body too large.'));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
resolve(JSON.parse(body || 'null'));
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
reject(error);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
req.on('error', reject);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
17
49
|
function sendJson(res, status, body, allowedOrigin) {
|
|
18
50
|
res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': allowedOrigin });
|
|
19
51
|
res.end(JSON.stringify(body));
|
|
@@ -33,6 +65,7 @@ export function createUpdateEndpoint(deps) {
|
|
|
33
65
|
// every real caller), so resetting this flag right after is safe and lets a later retry (after a
|
|
34
66
|
// `'failure'`, in the same process) through again.
|
|
35
67
|
let starting = false;
|
|
68
|
+
let checking = false;
|
|
36
69
|
return http.createServer(async (req, res) => {
|
|
37
70
|
// Controller decision 1 (required, not optional): reject a request whose `Host` header is not
|
|
38
71
|
// this very socket's own `127.0.0.1:<port>` — a DNS-rebinding defence, since `req.socket`'s own
|
|
@@ -55,7 +88,7 @@ export function createUpdateEndpoint(deps) {
|
|
|
55
88
|
res.writeHead(204, {
|
|
56
89
|
'Access-Control-Allow-Origin': deps.allowedOrigin,
|
|
57
90
|
'Access-Control-Allow-Methods': 'POST, GET',
|
|
58
|
-
'Access-Control-Allow-Headers': DS_VIEWER_SECRET_HEADER,
|
|
91
|
+
'Access-Control-Allow-Headers': `${DS_VIEWER_SECRET_HEADER}, Content-Type`,
|
|
59
92
|
});
|
|
60
93
|
res.end();
|
|
61
94
|
return;
|
|
@@ -137,6 +170,48 @@ export function createUpdateEndpoint(deps) {
|
|
|
137
170
|
sendJson(res, 200, deps.getStatus(), deps.allowedOrigin);
|
|
138
171
|
return;
|
|
139
172
|
}
|
|
173
|
+
if (req.method === 'GET' && req.url === '/version' && deps.getVersionStatus) {
|
|
174
|
+
sendJson(res, 200, deps.getVersionStatus(), deps.allowedOrigin);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (req.method === 'POST' && req.url === '/version/check' && deps.checkNow) {
|
|
178
|
+
if (checking) {
|
|
179
|
+
sendJson(res, 409, { error: 'A check is already running.' }, deps.allowedOrigin);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
checking = true;
|
|
183
|
+
try {
|
|
184
|
+
sendJson(res, 200, await deps.checkNow(), deps.allowedOrigin);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
sendJson(res, 502, { error: error.message }, deps.allowedOrigin);
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
checking = false;
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (req.method === 'POST' && req.url === '/version/auto-check' && deps.setAutoCheck) {
|
|
195
|
+
let body;
|
|
196
|
+
try {
|
|
197
|
+
body = await readJsonBody(req);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
body = undefined;
|
|
201
|
+
}
|
|
202
|
+
const enabled = body?.enabled;
|
|
203
|
+
if (typeof enabled !== 'boolean') {
|
|
204
|
+
sendJson(res, 400, { error: 'Expected { "enabled": true | false }.' }, deps.allowedOrigin);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
sendJson(res, 200, deps.setAutoCheck(enabled), deps.allowedOrigin);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
sendJson(res, 500, { error: error.message }, deps.allowedOrigin);
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
140
215
|
res.writeHead(404).end('Not found.');
|
|
141
216
|
});
|
|
142
217
|
}
|
package/dist/cli/main.js
CHANGED
|
@@ -8,7 +8,7 @@ import { sync } from "./sync.js";
|
|
|
8
8
|
import { formatHuman, runDoctor, toDoctorJson } from "./doctor.js";
|
|
9
9
|
import { explainPage, formatExplain, parseHeights } from "./explain.js";
|
|
10
10
|
import { readOwnVersion } from "./packageVersion.js";
|
|
11
|
-
import { checkForUpdate,
|
|
11
|
+
import { checkForUpdate, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck } from "./updateCheck.js";
|
|
12
12
|
import { runKitDiffCommand } from "./kitDiff.js";
|
|
13
13
|
import { formatMigrateHuman, runMigrate } from "./migrate.js";
|
|
14
14
|
import { runUpdate } from "./update.js";
|
|
@@ -119,7 +119,9 @@ export async function runDoctorCommand(projectRoot, rest, options = {}) {
|
|
|
119
119
|
const result = runDoctor(config);
|
|
120
120
|
// Design §4 "`--ci`: ... no update check." — `--ci` skips this whole block even when
|
|
121
121
|
// `updateCheck` is otherwise enabled; `result.update` stays `null`.
|
|
122
|
-
|
|
122
|
+
// Same precedence as the viewer: CI's environment variable, your own switch, the project config.
|
|
123
|
+
const autoCheck = resolveAutoCheck(config, options.env ?? process.env, readPersonalAutoCheck(options.prefsDir ?? defaultPreferencesDir(), config.projectRoot));
|
|
124
|
+
if (!rest.includes('--ci') && autoCheck.enabled) {
|
|
123
125
|
const ownVersion = readOwnVersion(path.dirname(fileURLToPath(import.meta.url)));
|
|
124
126
|
const updateResult = await (options.checkForUpdate ?? checkForUpdate)(ownVersion);
|
|
125
127
|
if (updateResult) {
|
package/dist/cli/updateCheck.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// cli/updateCheck.ts
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { compareVersions, isBreakingUpgrade, parseVersion } from "./semver.js";
|
|
5
5
|
const CACHE_FILE_NAME = 'update-check.json';
|
|
@@ -79,59 +79,163 @@ export async function fetchWithTimeout(url, fetchImpl, timeoutMs) {
|
|
|
79
79
|
clearTimeout(timer);
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
+
/** One update check, with whether npm actually answered — `checkForUpdate` alone can't tell "no
|
|
83
|
+
* newer version" from "couldn't reach npm", which the update page must say differently. Never
|
|
84
|
+
* throws. `cached` is the cache as it stands afterwards (the last successful check on this
|
|
85
|
+
* computer), when there is one. */
|
|
86
|
+
export async function runUpdateCheck(currentVersion, options = {}) {
|
|
87
|
+
const now = options.now ?? Date.now;
|
|
88
|
+
const cacheDir = options.cacheDir ?? cacheDirFor(process.platform, process.env, process.env.HOME ?? process.env.USERPROFILE ?? '');
|
|
89
|
+
const cached = readCache(cacheDir);
|
|
90
|
+
// `parseVersion` (inside `toResultOrUndefined`) throws on a non-semver `latest`, whether from a
|
|
91
|
+
// malformed cache file or an unexpected registry body; this function must never throw.
|
|
92
|
+
const safeResult = (entry) => {
|
|
93
|
+
try {
|
|
94
|
+
return toResultOrUndefined(entry, currentVersion);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
if (cached && !options.force && isCacheFresh(cached, now()))
|
|
101
|
+
return { result: safeResult(cached), reached: true, cached };
|
|
102
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
103
|
+
const timeoutMs = options.timeoutMs ?? 900;
|
|
104
|
+
let latest;
|
|
105
|
+
try {
|
|
106
|
+
const registryResponse = (await fetchWithTimeout(REGISTRY_URL, fetchImpl, timeoutMs));
|
|
107
|
+
latest = registryResponse.version;
|
|
108
|
+
parseVersion(latest);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// A stale cache is still better than nothing once offline — still run through the same
|
|
112
|
+
// not-actually-newer guard, never a raw passthrough.
|
|
113
|
+
return { result: cached ? safeResult(cached) : undefined, reached: false, cached };
|
|
114
|
+
}
|
|
115
|
+
let summary = [];
|
|
116
|
+
let releasedAt;
|
|
117
|
+
try {
|
|
118
|
+
const release = (await fetchWithTimeout(`https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${latest}`, fetchImpl, timeoutMs));
|
|
119
|
+
summary = release.body ? extractSummaryBullets(release.body) : [];
|
|
120
|
+
releasedAt = release.published_at;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// The version comparison is the half that matters for the footer/banner; a missing release
|
|
124
|
+
// summary (private repo hiccup, a tag pushed slightly before its release note) degrades to an
|
|
125
|
+
// empty "What's new" list, never a failed check.
|
|
126
|
+
}
|
|
127
|
+
const fresh = { latest, summary, releasedAt, checkedAt: new Date(now()).toISOString() };
|
|
128
|
+
// A registry that (briefly, during its own propagation) reports an older "latest" than what is
|
|
129
|
+
// already cached never regresses the cache's version, but the check time still moves forward.
|
|
130
|
+
let stored = fresh;
|
|
131
|
+
try {
|
|
132
|
+
if (cached && compareVersions(parseVersion(fresh.latest), parseVersion(cached.latest)) < 0)
|
|
133
|
+
stored = { ...cached, checkedAt: fresh.checkedAt };
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// An unreadable cached version is simply replaced.
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
writeCache(cacheDir, stored);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// A read-only cache folder must not turn a successful check into a failure.
|
|
143
|
+
}
|
|
144
|
+
return { result: safeResult(fresh), reached: true, cached: stored };
|
|
145
|
+
}
|
|
82
146
|
/** Design §5 "Update check" in full. Never throws — every failure (a stale/missing cache with no
|
|
83
147
|
* reachable registry, a GitHub release that 404s, a request that times out) resolves to either a
|
|
84
148
|
* usable result or `undefined`, so every caller can treat "no update known" as the one, uniform
|
|
85
149
|
* outcome of being offline, blocked, or genuinely up to date with nothing cached yet. */
|
|
86
150
|
export async function checkForUpdate(currentVersion, options = {}) {
|
|
87
|
-
// M5 (Minor, Fable correction pass): this function is documented — and relied on by `dev()`,
|
|
88
|
-
// which `await`s it before starting anything else — as never throwing. `parseVersion` (inside
|
|
89
|
-
// `toResultOrUndefined`) throws on a non-semver `latest`, whether from a parseable-but-malformed
|
|
90
|
-
// cache file or an unexpected 200 body with no real `version`; either would otherwise stop `dev`
|
|
91
|
-
// from starting at all.
|
|
92
151
|
try {
|
|
93
|
-
|
|
94
|
-
const cacheDir = options.cacheDir ?? cacheDirFor(process.platform, process.env, process.env.HOME ?? process.env.USERPROFILE ?? '');
|
|
95
|
-
const cached = readCache(cacheDir);
|
|
96
|
-
if (cached && isCacheFresh(cached, now()))
|
|
97
|
-
return toResultOrUndefined(cached, currentVersion);
|
|
98
|
-
const fetchImpl = options.fetchImpl ?? fetch;
|
|
99
|
-
const timeoutMs = options.timeoutMs ?? 900;
|
|
100
|
-
let latest;
|
|
101
|
-
try {
|
|
102
|
-
const registryResponse = (await fetchWithTimeout(REGISTRY_URL, fetchImpl, timeoutMs));
|
|
103
|
-
latest = registryResponse.version;
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
// A stale cache is still better than nothing once offline — still run through the same
|
|
107
|
-
// not-actually-newer guard, never a raw passthrough.
|
|
108
|
-
return cached ? toResultOrUndefined(cached, currentVersion) : undefined;
|
|
109
|
-
}
|
|
110
|
-
let summary = [];
|
|
111
|
-
let releasedAt;
|
|
112
|
-
try {
|
|
113
|
-
const release = (await fetchWithTimeout(`https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${latest}`, fetchImpl, timeoutMs));
|
|
114
|
-
summary = release.body ? extractSummaryBullets(release.body) : [];
|
|
115
|
-
releasedAt = release.published_at;
|
|
116
|
-
}
|
|
117
|
-
catch {
|
|
118
|
-
// The version comparison is the half that matters for the footer/banner; a missing release
|
|
119
|
-
// summary (private repo hiccup, a tag pushed slightly before its release note) degrades to an
|
|
120
|
-
// empty "What's new" list, never a failed check.
|
|
121
|
-
}
|
|
122
|
-
const cachedResult = { latest, summary, releasedAt, checkedAt: new Date(now()).toISOString() };
|
|
123
|
-
// compareVersions is used only to decide *whether* this is worth caching as "the latest" at all
|
|
124
|
-
// — a registry that (briefly, during its own propagation) reports an older "latest" than what
|
|
125
|
-
// is already cached never regresses the cache.
|
|
126
|
-
if (!cached || compareVersions(parseVersion(cachedResult.latest), parseVersion(cached.latest)) >= 0) {
|
|
127
|
-
writeCache(cacheDir, cachedResult);
|
|
128
|
-
}
|
|
129
|
-
return toResultOrUndefined(cachedResult, currentVersion);
|
|
152
|
+
return (await runUpdateCheck(currentVersion, options)).result;
|
|
130
153
|
}
|
|
131
154
|
catch {
|
|
132
155
|
return undefined;
|
|
133
156
|
}
|
|
134
157
|
}
|
|
158
|
+
/** A settings folder, not the cache folder (which the OS may clear): the personal switch must
|
|
159
|
+
* survive. */
|
|
160
|
+
export function preferencesDirFor(platform, env, homedir) {
|
|
161
|
+
if (platform === 'darwin')
|
|
162
|
+
return path.posix.join(homedir, 'Library', 'Application Support', 'ds-viewer');
|
|
163
|
+
if (platform === 'win32')
|
|
164
|
+
return path.win32.join(env.APPDATA ?? path.win32.join(homedir, 'AppData', 'Roaming'), 'ds-viewer');
|
|
165
|
+
return path.posix.join(env.XDG_CONFIG_HOME ?? path.posix.join(homedir, '.config'), 'ds-viewer');
|
|
166
|
+
}
|
|
167
|
+
export function defaultPreferencesDir() {
|
|
168
|
+
return preferencesDirFor(process.platform, process.env, process.env.HOME ?? process.env.USERPROFILE ?? '');
|
|
169
|
+
}
|
|
170
|
+
const PREFERENCES_FILE_NAME = 'preferences.json';
|
|
171
|
+
function readPreferences(dir) {
|
|
172
|
+
try {
|
|
173
|
+
const parsed = JSON.parse(readFileSync(path.join(dir, PREFERENCES_FILE_NAME), 'utf8'));
|
|
174
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return {};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Your own "check automatically" switch for one project on this computer, or `undefined` when you
|
|
181
|
+
* haven't set it (the project config then decides). */
|
|
182
|
+
export function readPersonalAutoCheck(dir, projectRoot) {
|
|
183
|
+
const value = readPreferences(dir).projects?.[path.resolve(projectRoot)]?.autoCheck;
|
|
184
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
185
|
+
}
|
|
186
|
+
export function writePersonalAutoCheck(dir, projectRoot, enabled) {
|
|
187
|
+
const prefs = readPreferences(dir);
|
|
188
|
+
const projects = { ...(prefs.projects ?? {}) };
|
|
189
|
+
projects[path.resolve(projectRoot)] = { ...projects[path.resolve(projectRoot)], autoCheck: enabled };
|
|
190
|
+
mkdirSync(dir, { recursive: true });
|
|
191
|
+
// Write then rename, so a crash mid-write never leaves a half file that would read as "no
|
|
192
|
+
// settings" and silently reset every project's switch.
|
|
193
|
+
const target = path.join(dir, PREFERENCES_FILE_NAME);
|
|
194
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
195
|
+
writeFileSync(temp, `${JSON.stringify({ ...prefs, projects }, null, 2)}\n`);
|
|
196
|
+
renameSync(temp, target);
|
|
197
|
+
}
|
|
198
|
+
/** Which setting decides automatic checks, strongest first: CI's environment variable, your own
|
|
199
|
+
* switch, the project config, then on by default. */
|
|
200
|
+
export function resolveAutoCheck(config, env, personal) {
|
|
201
|
+
if (env.DS_VIEWER_NO_UPDATE_CHECK === '1')
|
|
202
|
+
return { enabled: false, source: 'env' };
|
|
203
|
+
if (personal !== undefined)
|
|
204
|
+
return { enabled: personal, source: 'personal' };
|
|
205
|
+
if (config.updateCheck !== undefined)
|
|
206
|
+
return { enabled: config.updateCheck, source: 'project' };
|
|
207
|
+
return { enabled: true, source: 'default' };
|
|
208
|
+
}
|
|
209
|
+
function toNotice(result) {
|
|
210
|
+
if (!result)
|
|
211
|
+
return null;
|
|
212
|
+
return { current: result.current, latest: result.latest, breaking: result.breaking, summary: result.summary, releasedAt: result.releasedAt };
|
|
213
|
+
}
|
|
214
|
+
/** The version status `dev` starts with: runs the (cached) check only when automatic checks are on. */
|
|
215
|
+
export async function buildVersionStatus(currentVersion, autoCheck, options = {}) {
|
|
216
|
+
if (!autoCheck.enabled) {
|
|
217
|
+
const cacheDir = options.cacheDir ?? cacheDirFor(process.platform, process.env, process.env.HOME ?? process.env.USERPROFILE ?? '');
|
|
218
|
+
return { current: currentVersion, autoCheck, lastCheckedAt: readCache(cacheDir)?.checkedAt, lastOutcome: 'not-run', update: null };
|
|
219
|
+
}
|
|
220
|
+
return statusFromCheck(currentVersion, autoCheck, await runUpdateCheck(currentVersion, options));
|
|
221
|
+
}
|
|
222
|
+
/** Folds one check's outcome into a status (startup, or **Check now**). */
|
|
223
|
+
export function statusFromCheck(currentVersion, autoCheck, outcome) {
|
|
224
|
+
return {
|
|
225
|
+
current: currentVersion,
|
|
226
|
+
autoCheck,
|
|
227
|
+
lastCheckedAt: outcome.cached?.checkedAt,
|
|
228
|
+
lastOutcome: outcome.reached ? 'ok' : 'unreachable',
|
|
229
|
+
update: toNotice(outcome.result),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/** `dev` writes this next to `update.json`; the generated entry file imports it. */
|
|
233
|
+
export function writeVersionFile(projectRoot, status) {
|
|
234
|
+
const file = path.join(projectRoot, '.ds-viewer', 'version.json');
|
|
235
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
236
|
+
writeFileSync(file, JSON.stringify(status));
|
|
237
|
+
return file;
|
|
238
|
+
}
|
|
135
239
|
/** Design §5: "`dev` writes the result to `.ds-viewer/update.json`; the viewer reads it." Always
|
|
136
240
|
* writes something — `null` when there is no result — so the generated entry file (Task 17) can
|
|
137
241
|
* unconditionally `import updateNotice from './update.json'` without ever hitting a missing file. */
|
|
@@ -141,3 +245,30 @@ export function writeUpdateFile(projectRoot, result) {
|
|
|
141
245
|
writeFileSync(file, JSON.stringify(result ?? null));
|
|
142
246
|
return file;
|
|
143
247
|
}
|
|
248
|
+
/** The live version status behind the local endpoint (`GET /version`, **Check now**, the switch),
|
|
249
|
+
* kept outside `dev()` so its ordering rules are testable. */
|
|
250
|
+
export function createVersionController(deps) {
|
|
251
|
+
let status = deps.initial;
|
|
252
|
+
const resolveNow = () => resolveAutoCheck(deps.getConfig(), deps.env, readPersonalAutoCheck(deps.prefsDir, deps.projectRoot));
|
|
253
|
+
return {
|
|
254
|
+
getVersionStatus: () => status,
|
|
255
|
+
/** **Check now**: asks npm ignoring the cache. Reads the switch *after* the check, so a change
|
|
256
|
+
* made while it ran is kept. */
|
|
257
|
+
checkNow: async () => {
|
|
258
|
+
const outcome = await deps.runCheck();
|
|
259
|
+
status = statusFromCheck(deps.ownVersion, status.autoCheck, outcome);
|
|
260
|
+
return status;
|
|
261
|
+
},
|
|
262
|
+
/** The switch: your own setting for this project on this computer. Takes effect for automatic
|
|
263
|
+
* checks from the next start; the current status keeps any update already found. */
|
|
264
|
+
setAutoCheck: (enabled) => {
|
|
265
|
+
writePersonalAutoCheck(deps.prefsDir, deps.projectRoot, enabled);
|
|
266
|
+
status = { ...status, autoCheck: resolveNow() };
|
|
267
|
+
return status;
|
|
268
|
+
},
|
|
269
|
+
/** After `ds-viewer.config.ts` changes while `dev` runs. */
|
|
270
|
+
refreshAutoCheck: () => {
|
|
271
|
+
status = { ...status, autoCheck: resolveNow() };
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
package/dist/cli/workspace.js
CHANGED
|
@@ -65,13 +65,14 @@ function entryTsx(name, hasSafeArea, groupOrder, logoRequirePath, updateEndpoint
|
|
|
65
65
|
// (no reserved words in either key) — the same reasoning the existing `logoRequirePath` baking
|
|
66
66
|
// already relies on, extended to a whole object instead of one string.
|
|
67
67
|
const endpointConst = `const updateEndpoint = ${updateEndpoint ? JSON.stringify(updateEndpoint) : 'undefined'};\n`;
|
|
68
|
-
const viewer = `<CatalogShell appName={${JSON.stringify(name)}} title="Component Catalog" groups={groups} sections={sections}${logoProp} update={updateNotice} updateEndpoint={updateEndpoint} />`;
|
|
68
|
+
const viewer = `<CatalogShell appName={${JSON.stringify(name)}} title="Component Catalog" groups={groups} sections={sections}${logoProp} update={updateNotice} versionStatus={versionStatus} updateEndpoint={updateEndpoint} />`;
|
|
69
69
|
const root = hasSafeArea ? `<SafeAreaProvider>${viewer}</SafeAreaProvider>` : viewer;
|
|
70
70
|
return `import { registerRootComponent } from 'expo';
|
|
71
71
|
import { CatalogShell, buildCatalogSections } from '@krapwoo/ds-viewer';
|
|
72
72
|
${safeAreaImport}${logoImport}import pages from './generated/pages';
|
|
73
73
|
import components from './generated/components.json';
|
|
74
74
|
import updateNotice from './update.json';
|
|
75
|
+
import versionStatus from './version.json';
|
|
75
76
|
${endpointConst}
|
|
76
77
|
function App() {
|
|
77
78
|
const { sections, groups } = buildCatalogSections(pages, components, ${JSON.stringify(groupOrder)});
|
|
@@ -129,3 +129,14 @@ export function footerLabel(update) {
|
|
|
129
129
|
return undefined;
|
|
130
130
|
return `Update available · ${update.latest}`;
|
|
131
131
|
}
|
|
132
|
+
/** The sidebar's footer line: the blue "Update available" notice when a newer version is known
|
|
133
|
+
* (from startup or **Check now**), else the installed version, quietly. `undefined` only for a
|
|
134
|
+
* workspace generated before version status existed, which keeps the old notice-only footer. */
|
|
135
|
+
export function footerLink(update, status) {
|
|
136
|
+
const notice = footerLabel(update) ?? footerLabel(status?.update);
|
|
137
|
+
if (notice)
|
|
138
|
+
return { label: notice, kind: 'update' };
|
|
139
|
+
if (status)
|
|
140
|
+
return { label: `DS Viewer ${status.current}`, kind: 'version' };
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
@@ -111,6 +111,8 @@ export const CATALOG_COLOR = {
|
|
|
111
111
|
surfaceMuted: '#fafafa',
|
|
112
112
|
/** Hover and pressed feedback on nav rows and buttons. */
|
|
113
113
|
surfacePressed: '#f1f3f8',
|
|
114
|
+
/** An example's cell when the example itself is white (`specimenSurface`), darker than surfaceMuted so a white card's edges show. */
|
|
115
|
+
specimenStage: '#f2f2f2',
|
|
114
116
|
pageBackground: '#f6f6f4',
|
|
115
117
|
chip: '#eeeeee',
|
|
116
118
|
/** Active nav label, prop types, scale bars. */
|
|
@@ -5,8 +5,8 @@ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_MAX_CONTENT_WIDTH, CATALOG_RADIU
|
|
|
5
5
|
import { CatalogSidebar } from './CatalogSidebar';
|
|
6
6
|
import { SectionBlock } from './SectionBlock';
|
|
7
7
|
import { UpdatePanel } from './UpdatePanel';
|
|
8
|
-
import { groupLabelFor, hashForId, neighbors, orderedIds, resolveActiveFromHash, shouldShowMajorBanner,
|
|
9
|
-
import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice } from './types';
|
|
8
|
+
import { DS_VIEWER_SECRET_HEADER, footerLink, groupLabelFor, hashForId, neighbors, orderedIds, resolveActiveFromHash, shouldShowMajorBanner, UPDATE_PAGE_ID } from './catalogNavigation';
|
|
9
|
+
import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice, VersionStatus } from './types';
|
|
10
10
|
|
|
11
11
|
/** `active` can be a real page id, or the reserved update-page id — never both a generic `TId`
|
|
12
12
|
* constraint and a hardcoded string literal type at once, which is why this is its own alias. */
|
|
@@ -111,6 +111,7 @@ export function CatalogShell<TId extends string>({
|
|
|
111
111
|
sections,
|
|
112
112
|
defaultPreviewWidths,
|
|
113
113
|
update,
|
|
114
|
+
versionStatus,
|
|
114
115
|
updateEndpoint,
|
|
115
116
|
}: {
|
|
116
117
|
/** Short product/app name — the sidebar logo and the breadcrumb root. */
|
|
@@ -129,6 +130,12 @@ export function CatalogShell<TId extends string>({
|
|
|
129
130
|
* or already up to date; the viewer treats all three identically: no footer, no banner, no
|
|
130
131
|
* update page content). */
|
|
131
132
|
update?: UpdateNotice | null;
|
|
133
|
+
/** The installed version, whether automatic checks are on (and why), and the last check —
|
|
134
|
+
* `dev` writes it at startup (`.ds-viewer/version.json`), and the shell refreshes it from the
|
|
135
|
+
* local endpoint. With it, the sidebar always shows a version line and the update page offers
|
|
136
|
+
* **Check now** and the automatic-check switch. Without it (an older generated workspace), the
|
|
137
|
+
* footer appears only when an update is known, as before. */
|
|
138
|
+
versionStatus?: VersionStatus | null;
|
|
132
139
|
/** The local endpoint's base URL and per-run secret — `dev` generates both (Task 18) and
|
|
133
140
|
* `writeWorkspace`'s generated entry file bakes them in (Task 17). Absent in a build that
|
|
134
141
|
* disables the endpoint entirely — `UpdatePanel` degrades to showing the plan/notice with no
|
|
@@ -198,28 +205,56 @@ export function CatalogShell<TId extends string>({
|
|
|
198
205
|
|
|
199
206
|
const activeDef = active !== undefined && active !== UPDATE_PAGE_ID ? sectionsById.get(active) : undefined;
|
|
200
207
|
|
|
208
|
+
// The live version status: starts from what `dev` wrote at startup, then follows the local
|
|
209
|
+
// endpoint (a reload after **Check now** must not fall back to the startup snapshot).
|
|
210
|
+
const [liveStatus, setLiveStatus] = useState<VersionStatus | null>(versionStatus ?? null);
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (!updateEndpoint || !isWeb()) return;
|
|
213
|
+
let cancelled = false;
|
|
214
|
+
fetch(`${updateEndpoint.baseUrl}/version`, { headers: { [DS_VIEWER_SECRET_HEADER]: updateEndpoint.secret } })
|
|
215
|
+
.then((res) => (res.ok ? (res.json() as Promise<VersionStatus>) : undefined))
|
|
216
|
+
.then((next) => {
|
|
217
|
+
if (next && !cancelled) setLiveStatus(next);
|
|
218
|
+
})
|
|
219
|
+
.catch(() => {
|
|
220
|
+
// The startup snapshot stays; the endpoint may be restarting.
|
|
221
|
+
});
|
|
222
|
+
return () => {
|
|
223
|
+
cancelled = true;
|
|
224
|
+
};
|
|
225
|
+
}, [updateEndpoint]);
|
|
226
|
+
// With a live status, it is the truth (it includes a **Check now** result); otherwise the
|
|
227
|
+
// startup notice, as before.
|
|
228
|
+
const effectiveUpdate: UpdateNotice | null = liveStatus ? liveStatus.update : update ?? null;
|
|
229
|
+
|
|
201
230
|
const [dismissedMajor, setDismissedMajor] = useState<string | null>(() =>
|
|
202
231
|
isWeb() ? window.localStorage.getItem('ds-viewer-dismissed-major') : null,
|
|
203
232
|
);
|
|
204
233
|
const dismissMajorBanner = useCallback(() => {
|
|
205
|
-
if (!
|
|
206
|
-
if (isWeb()) window.localStorage.setItem('ds-viewer-dismissed-major',
|
|
207
|
-
setDismissedMajor(
|
|
208
|
-
}, [
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
: undefined;
|
|
234
|
+
if (!effectiveUpdate) return;
|
|
235
|
+
if (isWeb()) window.localStorage.setItem('ds-viewer-dismissed-major', effectiveUpdate.latest);
|
|
236
|
+
setDismissedMajor(effectiveUpdate.latest);
|
|
237
|
+
}, [effectiveUpdate]);
|
|
238
|
+
const link = footerLink(effectiveUpdate, liveStatus);
|
|
239
|
+
const footer = link ? { ...link, active: active === UPDATE_PAGE_ID, onPress: () => select(UPDATE_PAGE_ID) } : undefined;
|
|
212
240
|
// Design: the banner shows on every *normal* page; once you're already looking at the update
|
|
213
241
|
// page there is nothing left for "Review update" to do, so it's hidden there (confirmed against
|
|
214
242
|
// the approved mockup's own `frame()`: the banner only ever renders when `!isPanel`).
|
|
215
|
-
const showBanner = active !== UPDATE_PAGE_ID && shouldShowMajorBanner(
|
|
243
|
+
const showBanner = active !== UPDATE_PAGE_ID && shouldShowMajorBanner(effectiveUpdate, dismissedMajor);
|
|
216
244
|
|
|
217
245
|
return (
|
|
218
246
|
<View style={styles.root}>
|
|
219
247
|
<CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} labels={labels} active={active} onPress={select} footer={footer} />
|
|
220
248
|
<ScrollView ref={scrollRef} style={styles.main} contentContainerStyle={styles.mainContent}>
|
|
221
249
|
{active === UPDATE_PAGE_ID ? (
|
|
222
|
-
<UpdatePanel
|
|
250
|
+
<UpdatePanel
|
|
251
|
+
update={effectiveUpdate}
|
|
252
|
+
versionStatus={liveStatus}
|
|
253
|
+
onVersionStatus={setLiveStatus}
|
|
254
|
+
endpoint={updateEndpoint}
|
|
255
|
+
appName={appName}
|
|
256
|
+
headingRef={headingRef}
|
|
257
|
+
/>
|
|
223
258
|
) : activeDef ? (
|
|
224
259
|
<SectionBlock
|
|
225
260
|
key={activeDef.id}
|
|
@@ -229,7 +264,7 @@ export function CatalogShell<TId extends string>({
|
|
|
229
264
|
// The approved mockup's own `PAGE_BG` order: crumb, then banner, then title — the
|
|
230
265
|
// banner used to render above the breadcrumb instead (Important finding, Fable
|
|
231
266
|
// correction pass).
|
|
232
|
-
banner={showBanner &&
|
|
267
|
+
banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} /> : undefined}
|
|
233
268
|
pager={{
|
|
234
269
|
previousId: neighbors(order, activeDef.id).previous,
|
|
235
270
|
nextId: neighbors(order, activeDef.id).next,
|