@krapwoo/ds-viewer 0.3.0 → 0.4.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 +69 -0
- package/config/index.ts +4 -2
- package/dist/cli/dev.js +312 -15
- package/dist/cli/doctor.js +10 -0
- package/dist/cli/endpoint.js +142 -0
- package/dist/cli/githubAction.js +8 -11
- package/dist/cli/init.js +3 -3
- package/dist/cli/kitDiff.js +132 -0
- package/dist/cli/kitRoot.js +20 -0
- package/dist/cli/main.js +77 -18
- package/dist/cli/migrate.js +30 -0
- package/dist/cli/migrateEntry.js +19 -0
- package/dist/cli/migrations/index.js +14 -0
- package/dist/cli/migrations/types.js +9 -0
- package/dist/cli/packageManager.js +29 -0
- package/dist/cli/packageVersion.js +24 -0
- package/dist/cli/semver.js +26 -0
- package/dist/cli/update.js +93 -0
- package/dist/cli/updateCheck.js +143 -0
- package/dist/cli/updatePlan.js +202 -0
- package/dist/cli/workspace.js +10 -5
- package/dist/native/catalog/catalogNavigation.js +129 -0
- package/dist/native/catalog/tokens.js +15 -0
- package/native/catalog/CatalogShell.tsx +105 -11
- package/native/catalog/CatalogSidebar.tsx +58 -2
- package/native/catalog/SectionBlock.tsx +5 -0
- package/native/catalog/UpdatePanel.tsx +556 -0
- package/native/catalog/catalogNavigation.ts +70 -1
- package/native/catalog/index.ts +3 -0
- package/native/catalog/tokens.ts +15 -0
- package/native/catalog/types.ts +11 -0
- package/native/catalog/updatePanelState.ts +74 -0
- package/package.json +1 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// cli/updateCheck.ts
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { compareVersions, isBreakingUpgrade, parseVersion } from "./semver.js";
|
|
5
|
+
const CACHE_FILE_NAME = 'update-check.json';
|
|
6
|
+
const PACKAGE_NAME = '@krapwoo/ds-viewer';
|
|
7
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME).replace('%40', '@')}/latest`;
|
|
8
|
+
const GITHUB_REPO = 'krapwoo/design-system-viewer';
|
|
9
|
+
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
/** Design §5: a user-wide (not project-wide) cache folder, shared across every project running
|
|
11
|
+
* this CLI — one real release per package version, so there is nothing to key by project. */
|
|
12
|
+
export function cacheDirFor(platform, env, homedir) {
|
|
13
|
+
if (platform === 'darwin')
|
|
14
|
+
return path.posix.join(homedir, 'Library', 'Caches', 'ds-viewer');
|
|
15
|
+
if (platform === 'win32')
|
|
16
|
+
return path.win32.join(env.LOCALAPPDATA ?? path.win32.join(homedir, 'AppData', 'Local'), 'ds-viewer');
|
|
17
|
+
return path.posix.join(env.XDG_CACHE_HOME ?? path.posix.join(homedir, '.cache'), 'ds-viewer');
|
|
18
|
+
}
|
|
19
|
+
export function readCache(cacheDir) {
|
|
20
|
+
const file = path.join(cacheDir, CACHE_FILE_NAME);
|
|
21
|
+
if (!existsSync(file))
|
|
22
|
+
return undefined;
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function writeCache(cacheDir, cached) {
|
|
31
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
32
|
+
writeFileSync(path.join(cacheDir, CACHE_FILE_NAME), JSON.stringify(cached));
|
|
33
|
+
}
|
|
34
|
+
export function isCacheFresh(cache, nowMs) {
|
|
35
|
+
return nowMs - Date.parse(cache.checkedAt) < TWENTY_FOUR_HOURS_MS;
|
|
36
|
+
}
|
|
37
|
+
/** The one place "is this actually worth showing" is decided: a cached/fetched `latest` that is
|
|
38
|
+
* not strictly newer than the version asking (already up to date, or — this repository's own
|
|
39
|
+
* `kit-host` before a real publish — temporarily *ahead* of what the registry reports) resolves
|
|
40
|
+
* to `undefined`, the same uniform "no update known" every other non-case already returns
|
|
41
|
+
* (Critical finding, Fable correction pass: without this, a stale/foreign cache entry could show
|
|
42
|
+
* a downgrade, or keep showing an update that was already applied). */
|
|
43
|
+
function toResultOrUndefined(cached, currentVersion) {
|
|
44
|
+
const current = parseVersion(currentVersion);
|
|
45
|
+
const latest = parseVersion(cached.latest);
|
|
46
|
+
if (compareVersions(latest, current) <= 0)
|
|
47
|
+
return undefined;
|
|
48
|
+
return { ...cached, current: currentVersion, breaking: isBreakingUpgrade(current, latest) };
|
|
49
|
+
}
|
|
50
|
+
/** `config` only ever needs its one field here — a plain object literal, not the full
|
|
51
|
+
* `ResolvedConfig`, is deliberately accepted so this has no import-time dependency on `cli/types.ts`. */
|
|
52
|
+
export function isUpdateCheckEnabled(config, env) {
|
|
53
|
+
if (env.DS_VIEWER_NO_UPDATE_CHECK === '1')
|
|
54
|
+
return false;
|
|
55
|
+
return config.updateCheck ?? true;
|
|
56
|
+
}
|
|
57
|
+
/** `- ` or `* ` markdown list items only, trimmed, in order, capped at 5 — a release body's prose
|
|
58
|
+
* paragraphs are never shown as if they were bullets. */
|
|
59
|
+
export function extractSummaryBullets(body) {
|
|
60
|
+
return body
|
|
61
|
+
.split('\n')
|
|
62
|
+
.map((line) => /^[-*]\s+(.*)$/.exec(line.trim())?.[1])
|
|
63
|
+
.filter((line) => Boolean(line))
|
|
64
|
+
.slice(0, 5);
|
|
65
|
+
}
|
|
66
|
+
/** Exported for `cli/updatePlan.ts` (Task 9), which needs the exact same "don't hang forever on
|
|
67
|
+
* the GitHub release" behavior for its own plan-building fetch (Important finding, Fable
|
|
68
|
+
* correction pass: that fetch had no timeout of its own before). */
|
|
69
|
+
export async function fetchWithTimeout(url, fetchImpl, timeoutMs) {
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetchImpl(url, { signal: controller.signal });
|
|
74
|
+
if (!response.ok)
|
|
75
|
+
throw new Error(`${url} responded ${response.status}`);
|
|
76
|
+
return await response.json();
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Design §5 "Update check" in full. Never throws — every failure (a stale/missing cache with no
|
|
83
|
+
* reachable registry, a GitHub release that 404s, a request that times out) resolves to either a
|
|
84
|
+
* usable result or `undefined`, so every caller can treat "no update known" as the one, uniform
|
|
85
|
+
* outcome of being offline, blocked, or genuinely up to date with nothing cached yet. */
|
|
86
|
+
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
|
+
try {
|
|
93
|
+
const now = options.now ?? Date.now;
|
|
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);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Design §5: "`dev` writes the result to `.ds-viewer/update.json`; the viewer reads it." Always
|
|
136
|
+
* writes something — `null` when there is no result — so the generated entry file (Task 17) can
|
|
137
|
+
* unconditionally `import updateNotice from './update.json'` without ever hitting a missing file. */
|
|
138
|
+
export function writeUpdateFile(projectRoot, result) {
|
|
139
|
+
const file = path.join(projectRoot, '.ds-viewer', 'update.json');
|
|
140
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
141
|
+
writeFileSync(file, JSON.stringify(result ?? null));
|
|
142
|
+
return file;
|
|
143
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// cli/updatePlan.ts
|
|
2
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { detectPackageManager } from "./packageManager.js";
|
|
8
|
+
import { listKitFileDiffs } from "./kitDiff.js";
|
|
9
|
+
import { extractSummaryBullets, fetchWithTimeout } from "./updateCheck.js";
|
|
10
|
+
import { compareVersions, isBreakingUpgrade, parseVersion } from "./semver.js";
|
|
11
|
+
const PACKAGE_NAME = '@krapwoo/ds-viewer';
|
|
12
|
+
const OFFLINE_MESSAGE = "Couldn't prepare the update. You may be offline, or npm didn't respond. Nothing was changed.";
|
|
13
|
+
function tarballFileName(version) {
|
|
14
|
+
return `krapwoo-ds-viewer-${version}.tgz`;
|
|
15
|
+
}
|
|
16
|
+
function lockfileName(pm) {
|
|
17
|
+
if (pm === 'pnpm')
|
|
18
|
+
return 'pnpm-lock.yaml';
|
|
19
|
+
if (pm === 'yarn-classic' || pm === 'yarn-berry')
|
|
20
|
+
return 'yarn.lock';
|
|
21
|
+
return 'package-lock.json';
|
|
22
|
+
}
|
|
23
|
+
/** `git status --porcelain`'s format is `XY path` (plain) or `XY old -> new` (a rename) — the
|
|
24
|
+
* path starts at column 3 either way; a rename's own "dirty" identity is its *new* path (the one
|
|
25
|
+
* this plan's own `files` list names), never the old one (Important finding, Fable correction
|
|
26
|
+
* pass: `R old -> new` was previously read as the single literal path `"old -> new"`, which
|
|
27
|
+
* could never match a planned file). */
|
|
28
|
+
function parseDirtyPaths(porcelain) {
|
|
29
|
+
return new Set(porcelain
|
|
30
|
+
.split('\n')
|
|
31
|
+
.filter((line) => line.length > 3)
|
|
32
|
+
.map((line) => {
|
|
33
|
+
const raw = line.slice(3).trim();
|
|
34
|
+
const arrow = raw.indexOf(' -> ');
|
|
35
|
+
return arrow === -1 ? raw : raw.slice(arrow + 4);
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
/** Strips the repository-root prefix `git rev-parse --show-prefix` reports from every path in a
|
|
39
|
+
* `git status --porcelain` listing (both sides of a rename), so the result reads relative to
|
|
40
|
+
* `projectRoot` the same way every test's injected fake already does. */
|
|
41
|
+
export function stripRepoPrefix(porcelain, prefix) {
|
|
42
|
+
if (!prefix)
|
|
43
|
+
return porcelain;
|
|
44
|
+
return porcelain
|
|
45
|
+
.split('\n')
|
|
46
|
+
.map((line) => {
|
|
47
|
+
if (line.length <= 3)
|
|
48
|
+
return line;
|
|
49
|
+
const status = line.slice(0, 3);
|
|
50
|
+
const rest = line.slice(3).split(' -> ').map((part) => (part.startsWith(prefix) ? part.slice(prefix.length) : part));
|
|
51
|
+
return `${status}${rest.join(' -> ')}`;
|
|
52
|
+
})
|
|
53
|
+
.join('\n');
|
|
54
|
+
}
|
|
55
|
+
/** Thrown by `defaultGitStatus` for any git failure that is *not* "not a git repository" (or git
|
|
56
|
+
* missing) — `buildUpdatePlan` below catches exactly this to return a plan `{ error }`, naming
|
|
57
|
+
* git, instead of silently treating the failure as "outside a git repository" (Minor finding,
|
|
58
|
+
* Fable correction pass: `fatal: detected dubious ownership`, a corrupt repo, or any other
|
|
59
|
+
* non-zero exit previously fell into the same `undefined` branch as genuinely being outside a
|
|
60
|
+
* repository, fail-open on the feature's one core safeguard — the dirty-file check — the CLI then
|
|
61
|
+
* printing the misleading "(Not a git repository …)" note over a real error). */
|
|
62
|
+
export class GitStatusError extends Error {
|
|
63
|
+
}
|
|
64
|
+
function isMissingRepo(error) {
|
|
65
|
+
const e = error;
|
|
66
|
+
if (e.code === 'ENOENT')
|
|
67
|
+
return true;
|
|
68
|
+
return /not a git repository/i.test(e.stderr ?? e.message ?? '');
|
|
69
|
+
}
|
|
70
|
+
function defaultGitStatus(projectRoot, plannedPaths) {
|
|
71
|
+
let prefix;
|
|
72
|
+
try {
|
|
73
|
+
prefix = execFileSync('git', ['rev-parse', '--show-prefix'], { cwd: projectRoot, encoding: 'utf8' }).trim();
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (isMissingRepo(error))
|
|
77
|
+
return undefined;
|
|
78
|
+
throw new GitStatusError(`git rev-parse failed: ${error.message}`);
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
// Errata 6: pathspecs are relative to the cwd (`projectRoot`), so they are passed unprefixed
|
|
82
|
+
// — only the *output* paths (which `git status --porcelain` always reports relative to the
|
|
83
|
+
// repository root) need `stripRepoPrefix` below.
|
|
84
|
+
const porcelain = execFileSync('git', ['status', '--porcelain', '--', ...plannedPaths], { cwd: projectRoot, encoding: 'utf8' });
|
|
85
|
+
return stripRepoPrefix(porcelain, prefix);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
throw new GitStatusError(`git status failed: ${error.message}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Design §5 "Update plan", steps 1-3, in full. Every external effect is injected (Global
|
|
92
|
+
* Constraints) — the real defaults are a real `npm view`/`npm pack`/`tar`/`node migrate` and a
|
|
93
|
+
* real `git status --porcelain`, exactly what Task 20's own Verdaccio spike already exercised. */
|
|
94
|
+
export async function buildUpdatePlan(config, currentVersion, options = {}) {
|
|
95
|
+
const execFileAsync = promisify(execFile);
|
|
96
|
+
const execImpl = options.execImpl ?? (async (cmd, args, opts) => (await execFileAsync(cmd, args, opts)).stdout);
|
|
97
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
98
|
+
const tmpDirImpl = options.tmpDirImpl ?? (() => mkdtempSync(path.join(tmpdir(), 'ds-viewer-update-')));
|
|
99
|
+
const gitStatusImpl = options.gitStatusImpl ?? defaultGitStatus;
|
|
100
|
+
// Important finding, Fable correction pass: a real, unreachable registry otherwise hangs `npm
|
|
101
|
+
// view`/`npm pack` for minutes (observed: "Checking…" sat for 140s, with `/update/status` not
|
|
102
|
+
// answering at all in that time — this whole function runs inside the endpoint's own request
|
|
103
|
+
// handler). 20s is generous for a real, reachable registry, and short enough that offline reaches
|
|
104
|
+
// "Couldn't prepare" in a bounded time instead of hanging.
|
|
105
|
+
const NETWORK_TIMEOUT_MS = 20_000;
|
|
106
|
+
let targetVersion;
|
|
107
|
+
try {
|
|
108
|
+
targetVersion = (await execImpl('npm', ['view', PACKAGE_NAME, 'version'], { cwd: config.projectRoot, encoding: 'utf8', timeout: NETWORK_TIMEOUT_MS })).trim();
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return { error: OFFLINE_MESSAGE };
|
|
112
|
+
}
|
|
113
|
+
// Errata 13: "Already on the latest version" when target <= current, not only on equality — an
|
|
114
|
+
// installed version ahead of the registry's own `npm view` result (e.g. a local prerelease) is
|
|
115
|
+
// not something `update` should try to "downgrade" into.
|
|
116
|
+
if (compareVersions(parseVersion(targetVersion), parseVersion(currentVersion)) <= 0) {
|
|
117
|
+
return { error: `Already on the latest version (${currentVersion}).` };
|
|
118
|
+
}
|
|
119
|
+
let migrateResult;
|
|
120
|
+
// M4 (Minor, Fable correction pass): compared against the *downloaded target version's* own
|
|
121
|
+
// `starter-kit/`, not the currently-installed one — the copy ("differ from {latest}'s kit") and
|
|
122
|
+
// design §5 both name the target, never what's already on disk. Computed here, before the
|
|
123
|
+
// `finally` below cleans the extracted tarball up.
|
|
124
|
+
let kitFilesDiffering = 0;
|
|
125
|
+
let tmp;
|
|
126
|
+
try {
|
|
127
|
+
tmp = tmpDirImpl();
|
|
128
|
+
// Step 1: "Download the target version into a temporary folder" — `npm pack` is an ordinary
|
|
129
|
+
// install-shaped request (it honors the project's own `.npmrc`/registry config exactly like
|
|
130
|
+
// `npm install` would), never a URL this file hardcodes.
|
|
131
|
+
await execImpl('npm', ['pack', `${PACKAGE_NAME}@${targetVersion}`, '--silent', '--pack-destination', tmp], { cwd: config.projectRoot, encoding: 'utf8', timeout: NETWORK_TIMEOUT_MS });
|
|
132
|
+
await execImpl('tar', ['-xzf', path.join(tmp, tarballFileName(targetVersion)), '-C', tmp], { cwd: tmp, encoding: 'utf8' });
|
|
133
|
+
// Step 2: the *downloaded* version's own migrations, never the currently-running one — so the
|
|
134
|
+
// plan always reflects the migrations the version being installed actually ships. This runs
|
|
135
|
+
// `migrateEntry.js`, never `main.js` (Critical finding, Fable correction pass, spiked for real
|
|
136
|
+
// in `spikes/migrate-entry/`): the extracted tarball has no `node_modules` of its own, and
|
|
137
|
+
// `main.js`'s own import chain pulls in the `typescript` npm package at module load, which
|
|
138
|
+
// would fail there with `ERR_MODULE_NOT_FOUND`; `migrateEntry.js` imports nothing but
|
|
139
|
+
// `migrate.ts`/`migrations/`/`semver.ts`, none of which may ever import `typescript`.
|
|
140
|
+
const migrateOutput = await execImpl('node', [path.join(tmp, 'package', 'dist', 'cli', 'migrateEntry.js'), '--from', currentVersion, '--dry-run', '--json'], { cwd: config.projectRoot, encoding: 'utf8' });
|
|
141
|
+
migrateResult = JSON.parse(migrateOutput);
|
|
142
|
+
kitFilesDiffering = listKitFileDiffs(config, path.join(tmp, 'package', 'starter-kit')).filter((d) => d.status !== 'identical').length;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return { error: OFFLINE_MESSAGE };
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
// Minor finding, Fable correction pass: every "Check again" (a fresh plan) used to leak this
|
|
149
|
+
// tarball and its extracted folder — cleaned up here regardless of success or failure.
|
|
150
|
+
if (tmp)
|
|
151
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
const pm = detectPackageManager(config.projectRoot);
|
|
154
|
+
const files = [
|
|
155
|
+
{ path: 'package.json', reason: 'version', dirty: false },
|
|
156
|
+
{ path: lockfileName(pm), reason: 'version', dirty: false },
|
|
157
|
+
...migrateResult.changes.map((change) => ({ path: change.file, reason: `migration: ${change.change}`, dirty: false })),
|
|
158
|
+
];
|
|
159
|
+
// Scoped to exactly the files above (decision 4 (brief): "limited to planned files") — called
|
|
160
|
+
// only now that `files` is known, so the real `defaultGitStatus` can pass their paths as `git
|
|
161
|
+
// status`'s own pathspec.
|
|
162
|
+
// M9 (Minor, Fable correction pass): `gitStatusImpl` throwing (rather than returning `undefined`,
|
|
163
|
+
// reserved for "outside a git repository") means a real git failure — fail closed with a plan
|
|
164
|
+
// `{ error }` naming git, never silently fall back to "outside a git repository, nothing is
|
|
165
|
+
// dirty" the way returning `undefined` here would.
|
|
166
|
+
let porcelain;
|
|
167
|
+
try {
|
|
168
|
+
porcelain = gitStatusImpl(config.projectRoot, files.map((f) => f.path));
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
return { error: `Couldn't check for uncommitted changes (git): ${error.message}` };
|
|
172
|
+
}
|
|
173
|
+
const outsideGitRepo = porcelain === undefined;
|
|
174
|
+
const dirtyPaths = porcelain === undefined ? new Set() : parseDirtyPaths(porcelain);
|
|
175
|
+
for (const file of files)
|
|
176
|
+
file.dirty = dirtyPaths.has(file.path);
|
|
177
|
+
let summary = [];
|
|
178
|
+
let releasedAt;
|
|
179
|
+
try {
|
|
180
|
+
// `fetchWithTimeout` (Task 4, exported) — a black-holed `api.github.com` must not hang this
|
|
181
|
+
// plan forever (Important finding, Fable correction pass: this request previously had no
|
|
182
|
+
// timeout of its own, unlike the identical request inside `checkForUpdate`).
|
|
183
|
+
const release = (await fetchWithTimeout(`https://api.github.com/repos/krapwoo/design-system-viewer/releases/tags/v${targetVersion}`, fetchImpl, 900));
|
|
184
|
+
summary = release.body ? extractSummaryBullets(release.body) : [];
|
|
185
|
+
releasedAt = release.published_at;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// Non-fatal — Task 4's identical reasoning: the version/migration half of the plan is what
|
|
189
|
+
// matters; a missing release summary degrades to an empty "What's new" list.
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
current: currentVersion,
|
|
193
|
+
latest: targetVersion,
|
|
194
|
+
breaking: isBreakingUpgrade(parseVersion(currentVersion), parseVersion(targetVersion)),
|
|
195
|
+
summary,
|
|
196
|
+
releasedAt,
|
|
197
|
+
files,
|
|
198
|
+
dirtyFiles: files.filter((f) => f.dirty).map((f) => f.path),
|
|
199
|
+
kitFilesDiffering,
|
|
200
|
+
outsideGitRepo,
|
|
201
|
+
};
|
|
202
|
+
}
|
package/dist/cli/workspace.js
CHANGED
|
@@ -49,7 +49,7 @@ module.exports = config;
|
|
|
49
49
|
* at bundle time, so a runtime try/catch `require` cannot skip it when the optional peer is
|
|
50
50
|
* missing. Importing and wrapping must instead be baked into the generated entry (design §1
|
|
51
51
|
* "Three parts", Viewer row). */
|
|
52
|
-
function entryTsx(name, hasSafeArea, groupOrder, logoRequirePath) {
|
|
52
|
+
function entryTsx(name, hasSafeArea, groupOrder, logoRequirePath, updateEndpoint) {
|
|
53
53
|
const safeAreaImport = hasSafeArea ? "import { SafeAreaProvider } from 'react-native-safe-area-context';\n" : '';
|
|
54
54
|
// A literal `require(...)` with a path computed at generation time: Metro resolves every import
|
|
55
55
|
// statically, so the logo's actual (arbitrary, user-configured) location must already be baked
|
|
@@ -61,13 +61,18 @@ function entryTsx(name, hasSafeArea, groupOrder, logoRequirePath) {
|
|
|
61
61
|
// Fable's implementation review, cli/workspace.ts:65).
|
|
62
62
|
const logoImport = logoRequirePath ? `const logoSource = require(${JSON.stringify(logoRequirePath)});\n` : '';
|
|
63
63
|
const logoProp = logoRequirePath ? ' logoImageSource={logoSource}' : '';
|
|
64
|
-
|
|
64
|
+
// `JSON.stringify` of a plain `{ baseUrl, secret }` object is valid JS object-literal syntax too
|
|
65
|
+
// (no reserved words in either key) — the same reasoning the existing `logoRequirePath` baking
|
|
66
|
+
// already relies on, extended to a whole object instead of one string.
|
|
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} />`;
|
|
65
69
|
const root = hasSafeArea ? `<SafeAreaProvider>${viewer}</SafeAreaProvider>` : viewer;
|
|
66
70
|
return `import { registerRootComponent } from 'expo';
|
|
67
71
|
import { CatalogShell, buildCatalogSections } from '@krapwoo/ds-viewer';
|
|
68
72
|
${safeAreaImport}${logoImport}import pages from './generated/pages';
|
|
69
73
|
import components from './generated/components.json';
|
|
70
|
-
|
|
74
|
+
import updateNotice from './update.json';
|
|
75
|
+
${endpointConst}
|
|
71
76
|
function App() {
|
|
72
77
|
const { sections, groups } = buildCatalogSections(pages, components, ${JSON.stringify(groupOrder)});
|
|
73
78
|
return ${root};
|
|
@@ -96,7 +101,7 @@ function projectIncludeGlobs(config) {
|
|
|
96
101
|
/** Writes the self-contained Expo web workspace at `.ds-viewer/` (design §2 "The preview
|
|
97
102
|
* workspace and `npx ds-viewer dev`"). Rewritten freely on every `dev` run — nothing here is
|
|
98
103
|
* user-owned. */
|
|
99
|
-
export function writeWorkspace(config) {
|
|
104
|
+
export function writeWorkspace(config, updateEndpoint) {
|
|
100
105
|
const workspace = path.join(config.projectRoot, '.ds-viewer');
|
|
101
106
|
mkdirSync(workspace, { recursive: true });
|
|
102
107
|
writeFileSync(path.join(workspace, 'package.json'), `${JSON.stringify({ name: 'ds-viewer-workspace', private: true, main: 'entry.tsx' }, null, 2)}\n`);
|
|
@@ -122,6 +127,6 @@ export function writeWorkspace(config) {
|
|
|
122
127
|
const logoRequirePath = config.logo
|
|
123
128
|
? path.relative(workspace, path.resolve(config.projectRoot, config.logo)).split(path.sep).join('/')
|
|
124
129
|
: undefined;
|
|
125
|
-
writeFileSync(path.join(workspace, 'entry.tsx'), entryTsx(config.name, hasSafeArea, config.groupOrder ?? [], logoRequirePath));
|
|
130
|
+
writeFileSync(path.join(workspace, 'entry.tsx'), entryTsx(config.name, hasSafeArea, config.groupOrder ?? [], logoRequirePath, updateEndpoint));
|
|
126
131
|
return workspace;
|
|
127
132
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** THE canonical within-group ordering of section ids. */
|
|
2
|
+
export function sortIds(ids) {
|
|
3
|
+
return ids.slice().sort((a, b) => a.localeCompare(b));
|
|
4
|
+
}
|
|
5
|
+
/** Every page id in sidebar order: groups in declared order, ids sorted within each group,
|
|
6
|
+
* first occurrence wins, and ids without a matching section (when `availableIds` is given) are
|
|
7
|
+
* dropped. */
|
|
8
|
+
export function orderedIds(groups, availableIds) {
|
|
9
|
+
const seen = new Set();
|
|
10
|
+
const order = [];
|
|
11
|
+
for (const group of groups) {
|
|
12
|
+
for (const id of sortIds(group.ids)) {
|
|
13
|
+
if (seen.has(id))
|
|
14
|
+
continue;
|
|
15
|
+
if (availableIds && !availableIds.has(id))
|
|
16
|
+
continue;
|
|
17
|
+
seen.add(id);
|
|
18
|
+
order.push(id);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return order;
|
|
22
|
+
}
|
|
23
|
+
export function groupLabelFor(groups, id) {
|
|
24
|
+
return groups.find((group) => group.ids.includes(id))?.label;
|
|
25
|
+
}
|
|
26
|
+
/** Previous and next page ids. Never wraps; null marks an unavailable direction. */
|
|
27
|
+
export function neighbors(order, id) {
|
|
28
|
+
const index = order.indexOf(id);
|
|
29
|
+
if (index < 0)
|
|
30
|
+
return { previous: null, next: null };
|
|
31
|
+
return {
|
|
32
|
+
previous: index > 0 ? order[index - 1] : null,
|
|
33
|
+
next: index < order.length - 1 ? order[index + 1] : null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Sidebar filter: case-insensitive substring match, sorted ids, empty groups removed. */
|
|
37
|
+
export function filterGroups(groups, query) {
|
|
38
|
+
const q = query.trim().toLowerCase();
|
|
39
|
+
return groups
|
|
40
|
+
.map((group) => ({
|
|
41
|
+
label: group.label,
|
|
42
|
+
ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q)) : group.ids),
|
|
43
|
+
}))
|
|
44
|
+
.filter((group) => group.ids.length > 0);
|
|
45
|
+
}
|
|
46
|
+
export function hashForId(id) {
|
|
47
|
+
return `#${encodeURIComponent(id)}`;
|
|
48
|
+
}
|
|
49
|
+
/** The page named by a URL fragment, or the first page when the fragment is empty, malformed, or
|
|
50
|
+
* unknown. Undefined only when there are no pages. */
|
|
51
|
+
export function idFromHash(hash, order) {
|
|
52
|
+
const raw = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
53
|
+
let decoded = '';
|
|
54
|
+
try {
|
|
55
|
+
decoded = decodeURIComponent(raw);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
decoded = '';
|
|
59
|
+
}
|
|
60
|
+
const match = order.find((id) => id === decoded);
|
|
61
|
+
return match ?? order[0];
|
|
62
|
+
}
|
|
63
|
+
/** Design §5: the update panel is its own catalog page at this fragment, "not listed in the
|
|
64
|
+
* sidebar groups" — it is therefore never a member of `order`, so `idFromHash` alone would treat
|
|
65
|
+
* it as an unknown fragment and fall back to the first real page. */
|
|
66
|
+
export const UPDATE_PAGE_ID = 'ds-viewer-update';
|
|
67
|
+
/** The request header both the local endpoint (Task 12, which checks it) and the panel's own
|
|
68
|
+
* fetch calls (Task 16, which send it) use for the per-`dev`-run secret (design §5 "Local
|
|
69
|
+
* endpoint safeguards"). Defined once, here, rather than as a literal string repeated in both the
|
|
70
|
+
* `cli/` and `native/catalog/` halves of the package, which may never import from each other in
|
|
71
|
+
* the other direction (`cli/` imports `native/catalog/`; `native/catalog/` imports nothing from
|
|
72
|
+
* `cli/`). */
|
|
73
|
+
export const DS_VIEWER_SECRET_HEADER = 'x-ds-viewer-secret';
|
|
74
|
+
/** A minimal `major.minor.patch` comparison, duplicated from `cli/semver.ts`'s own
|
|
75
|
+
* `compareVersions` rather than imported — `native/catalog/` is the published package root and
|
|
76
|
+
* never imports from `cli/` (the same reasoning `native/catalog/updatePanelState.ts`'s own
|
|
77
|
+
* `UpdatePlanView`/`UpdateStatusView`, Task 15, already established for duplicated types). Used
|
|
78
|
+
* only as a defense-in-depth guard (Critical finding, Fable correction pass) alongside
|
|
79
|
+
* `checkForUpdate`'s own newer-than-current check (Task 4) — not a general-purpose semver
|
|
80
|
+
* comparator, the same scope note `cli/semver.ts`'s own `Version` interface already carries.
|
|
81
|
+
* Errata 5: parses with a regex rather than `split('.').map(Number)` so a prerelease suffix on
|
|
82
|
+
* either string (e.g. `0.4.1-verify`) doesn't turn the patch component into `NaN`; a string that
|
|
83
|
+
* doesn't match at all is treated as not newer. */
|
|
84
|
+
function isNewerVersion(a, b) {
|
|
85
|
+
const ma = a.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
86
|
+
const mb = b.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
87
|
+
if (!ma || !mb)
|
|
88
|
+
return false;
|
|
89
|
+
for (let i = 1; i <= 3; i += 1) {
|
|
90
|
+
const na = Number(ma[i]);
|
|
91
|
+
const nb = Number(mb[i]);
|
|
92
|
+
if (na !== nb)
|
|
93
|
+
return na > nb;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
export function resolveActiveFromHash(hash, order) {
|
|
98
|
+
const raw = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
99
|
+
let decoded = '';
|
|
100
|
+
try {
|
|
101
|
+
decoded = decodeURIComponent(raw);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
decoded = '';
|
|
105
|
+
}
|
|
106
|
+
if (decoded === UPDATE_PAGE_ID)
|
|
107
|
+
return UPDATE_PAGE_ID;
|
|
108
|
+
return idFromHash(hash, order);
|
|
109
|
+
}
|
|
110
|
+
/** Design §5: "Major: ... dismissible per version; the footer line remains." `dismissedVersion`
|
|
111
|
+
* is whatever a previous dismissal recorded (`CatalogShell`'s own `localStorage` read, Task 14) —
|
|
112
|
+
* any value other than the *current* `update.latest` (including `null`, or an older dismissed
|
|
113
|
+
* major) still shows the banner, so dismissing 1.0.0 does not also suppress a later 2.0.0. */
|
|
114
|
+
export function shouldShowMajorBanner(update, dismissedVersion) {
|
|
115
|
+
if (!update || !update.breaking)
|
|
116
|
+
return false;
|
|
117
|
+
if (!isNewerVersion(update.latest, update.current))
|
|
118
|
+
return false;
|
|
119
|
+
return update.latest !== dismissedVersion;
|
|
120
|
+
}
|
|
121
|
+
/** The quiet footer line's exact copy (design §5: `"Update available · 0.5.0"`), or `undefined`
|
|
122
|
+
* when there is nothing to show. Independent of `shouldShowMajorBanner` — design: the footer line
|
|
123
|
+
* shows for *every* available update, minor or major, and stays even once the major banner is
|
|
124
|
+
* dismissed. */
|
|
125
|
+
export function footerLabel(update) {
|
|
126
|
+
if (!update || !isNewerVersion(update.latest, update.current))
|
|
127
|
+
return undefined;
|
|
128
|
+
return `Update available · ${update.latest}`;
|
|
129
|
+
}
|
|
@@ -59,6 +59,10 @@ export const CATALOG_LAYOUT = {
|
|
|
59
59
|
logoMark: 32,
|
|
60
60
|
logoWordmarkHeight: 28,
|
|
61
61
|
logoCaptionGap: 6,
|
|
62
|
+
/** Max width of the update page's content column (approved mockup: direction "C · Update
|
|
63
|
+
* page", content at most 512px) — independent of `CATALOG_MAX_CONTENT_WIDTH` (1200px), which
|
|
64
|
+
* is every other page's column width. */
|
|
65
|
+
updateContentMaxWidth: 512,
|
|
62
66
|
};
|
|
63
67
|
/** Max width of the main content column. */
|
|
64
68
|
export const CATALOG_MAX_CONTENT_WIDTH = 1200;
|
|
@@ -116,4 +120,15 @@ export const CATALOG_COLOR = {
|
|
|
116
120
|
/** 3px keyboard focus ring on every catalog control. */
|
|
117
121
|
focusRing: '#c9d7ff',
|
|
118
122
|
code: 'Menlo',
|
|
123
|
+
/** Major-update banner background/border/text (design §5 "Viewer notice"). */
|
|
124
|
+
warning: '#8a4b00',
|
|
125
|
+
warningSubtle: '#fff4e0',
|
|
126
|
+
warningBorder: '#f0cf97',
|
|
127
|
+
/** "Can't prepare"/failure note-boxes on the update page. */
|
|
128
|
+
danger: '#b3261e',
|
|
129
|
+
dangerSubtle: '#fdecea',
|
|
130
|
+
dangerBorder: '#f2b8b5',
|
|
131
|
+
/** The update page's post-update success note-box and "Updated" pill. */
|
|
132
|
+
success: '#1e6b3a',
|
|
133
|
+
successSubtle: '#e8f5ec',
|
|
119
134
|
};
|