@krapwoo/ds-viewer 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +182 -78
- package/dist/cli/agentsFile.js +31 -0
- package/dist/cli/config.js +26 -1
- package/dist/cli/dev.js +50 -5
- package/dist/cli/doctor.js +492 -0
- package/dist/cli/explain.js +151 -0
- package/dist/cli/githubAction.js +68 -0
- package/dist/cli/glob.js +8 -0
- package/dist/cli/init.js +152 -18
- package/dist/cli/main.js +115 -4
- package/dist/cli/pageIndex.js +27 -1
- package/dist/cli/props.js +170 -21
- package/dist/cli/staticPage.js +356 -0
- package/dist/cli/sync.js +28 -5
- package/dist/cli/workspace.js +49 -21
- package/dist/native/catalog/comparison.js +283 -0
- package/dist/native/catalog/tokens.js +119 -0
- package/dist/native/catalog/types.js +1 -0
- package/native/catalog/CatalogShell.tsx +6 -1
- package/native/catalog/CatalogSidebar.tsx +98 -5
- package/native/catalog/ComparisonGrid.tsx +9 -7
- package/native/catalog/SpacingScaleGallery.tsx +1 -1
- package/native/catalog/TokenRow.tsx +3 -2
- package/native/catalog/catalogNavigation.ts +1 -1
- package/native/catalog/comparison.ts +63 -5
- package/native/catalog/index.ts +8 -6
- package/native/catalog/logoLayout.ts +12 -0
- package/native/catalog/pageApi.ts +21 -3
- package/native/catalog/tokens.ts +5 -0
- package/native/catalog/types.ts +9 -6
- package/package.json +12 -6
- package/starter-kit/WHEN_TO_USE.md +124 -0
- package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +23 -0
- package/starter-kit/components/AnimatedChevron/AnimatedChevron.tsx +46 -0
- package/starter-kit/components/AnimatedChevron/index.ts +2 -0
- package/starter-kit/components/Avatar/Avatar.catalog.tsx +54 -0
- package/starter-kit/components/Avatar/Avatar.tsx +74 -0
- package/starter-kit/components/Avatar/index.ts +2 -0
- package/starter-kit/components/Badge/Badge.catalog.tsx +88 -0
- package/starter-kit/components/Badge/Badge.tsx +56 -0
- package/starter-kit/components/Badge/Badge.types.ts +18 -0
- package/starter-kit/components/Badge/index.ts +2 -0
- package/starter-kit/components/Banner/Banner.catalog.tsx +87 -0
- package/starter-kit/components/Banner/Banner.tsx +257 -0
- package/starter-kit/components/Banner/Banner.types.ts +38 -0
- package/starter-kit/components/Banner/index.ts +2 -0
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +77 -0
- package/starter-kit/components/BottomSheet/BottomSheet.tsx +151 -0
- package/starter-kit/components/BottomSheet/index.ts +2 -0
- package/starter-kit/components/Button/Button.catalog.tsx +99 -0
- package/starter-kit/components/Button/Button.tsx +173 -0
- package/starter-kit/components/Button/Button.types.ts +52 -0
- package/starter-kit/components/Button/index.ts +2 -0
- package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +39 -0
- package/starter-kit/components/ButtonGroup/ButtonGroup.tsx +84 -0
- package/starter-kit/components/ButtonGroup/index.ts +2 -0
- package/starter-kit/components/Card/Card.catalog.tsx +68 -0
- package/starter-kit/components/Card/Card.tsx +133 -0
- package/starter-kit/components/Card/index.ts +2 -0
- package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +28 -0
- package/starter-kit/components/Checkbox/Checkbox.tsx +104 -0
- package/starter-kit/components/Checkbox/index.ts +2 -0
- package/starter-kit/components/Dialog/Dialog.catalog.tsx +41 -0
- package/starter-kit/components/Dialog/Dialog.tsx +82 -0
- package/starter-kit/components/Dialog/index.ts +2 -0
- package/starter-kit/components/Divider/Divider.catalog.tsx +35 -0
- package/starter-kit/components/Divider/Divider.tsx +20 -0
- package/starter-kit/components/Divider/index.ts +2 -0
- package/starter-kit/components/Dock/Dock.catalog.tsx +71 -0
- package/starter-kit/components/Dock/Dock.tsx +58 -0
- package/starter-kit/components/Dock/index.ts +2 -0
- package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +69 -0
- package/starter-kit/components/Dropdown/Dropdown.tsx +162 -0
- package/starter-kit/components/Dropdown/index.ts +2 -0
- package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +67 -0
- package/starter-kit/components/EmptyState/EmptyState.tsx +88 -0
- package/starter-kit/components/EmptyState/index.ts +2 -0
- package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +76 -0
- package/starter-kit/components/FieldContainer/FieldContainer.tsx +99 -0
- package/starter-kit/components/FieldContainer/FieldContainer.types.ts +27 -0
- package/starter-kit/components/FieldContainer/index.ts +2 -0
- package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +18 -0
- package/starter-kit/components/InputClearButton/InputClearButton.tsx +63 -0
- package/starter-kit/components/InputClearButton/index.ts +2 -0
- package/starter-kit/components/InputField/InputField.catalog.tsx +76 -0
- package/starter-kit/components/InputField/InputField.tsx +418 -0
- package/starter-kit/components/InputField/index.ts +2 -0
- package/starter-kit/components/List/List.catalog.tsx +30 -0
- package/starter-kit/components/List/List.tsx +36 -0
- package/starter-kit/components/List/index.ts +2 -0
- package/starter-kit/components/ListItem/ListItem.catalog.tsx +89 -0
- package/starter-kit/components/ListItem/ListItem.tsx +165 -0
- package/starter-kit/components/ListItem/index.ts +2 -0
- package/starter-kit/components/Loading/Loading.catalog.tsx +34 -0
- package/starter-kit/components/Loading/Loading.tsx +121 -0
- package/starter-kit/components/Loading/index.ts +2 -0
- package/starter-kit/components/Pill/Pill.catalog.tsx +79 -0
- package/starter-kit/components/Pill/Pill.tsx +181 -0
- package/starter-kit/components/Pill/Pill.types.ts +30 -0
- package/starter-kit/components/Pill/index.ts +2 -0
- package/starter-kit/components/PillRow/PillRow.catalog.tsx +91 -0
- package/starter-kit/components/PillRow/PillRow.tsx +140 -0
- package/starter-kit/components/PillRow/PillRow.types.ts +24 -0
- package/starter-kit/components/PillRow/index.ts +2 -0
- package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +22 -0
- package/starter-kit/components/ProgressDots/ProgressDots.tsx +42 -0
- package/starter-kit/components/ProgressDots/index.ts +2 -0
- package/starter-kit/components/Radio/Radio.catalog.tsx +38 -0
- package/starter-kit/components/Radio/Radio.tsx +114 -0
- package/starter-kit/components/Radio/index.ts +2 -0
- package/starter-kit/components/Radio/usePressScale.ts +33 -0
- package/starter-kit/components/SearchField/SearchField.catalog.tsx +28 -0
- package/starter-kit/components/SearchField/SearchField.tsx +98 -0
- package/starter-kit/components/SearchField/index.ts +2 -0
- package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +72 -0
- package/starter-kit/components/SectionHeader/SectionHeader.tsx +139 -0
- package/starter-kit/components/SectionHeader/index.ts +2 -0
- package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +31 -0
- package/starter-kit/components/SegmentedToggle/SegmentedToggle.tsx +163 -0
- package/starter-kit/components/SegmentedToggle/index.ts +2 -0
- package/starter-kit/components/SegmentedToggle/useSlideAnim.ts +29 -0
- package/starter-kit/components/Shimmer/Breathing.catalog.tsx +29 -0
- package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +89 -0
- package/starter-kit/components/Shimmer/Shimmer.tsx +158 -0
- package/starter-kit/components/Shimmer/Shimmer.types.ts +27 -0
- package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +39 -0
- package/starter-kit/components/Shimmer/index.ts +2 -0
- package/starter-kit/components/Surface/Surface.catalog.tsx +44 -0
- package/starter-kit/components/Surface/Surface.tsx +45 -0
- package/starter-kit/components/Surface/index.ts +2 -0
- package/starter-kit/components/Switch/Switch.catalog.tsx +33 -0
- package/starter-kit/components/Switch/Switch.tsx +129 -0
- package/starter-kit/components/Switch/index.ts +2 -0
- package/starter-kit/components/TextArea/TextArea.catalog.tsx +29 -0
- package/starter-kit/components/TextArea/TextArea.tsx +99 -0
- package/starter-kit/components/TextArea/index.ts +2 -0
- package/starter-kit/components/Toast/Toast.catalog.tsx +61 -0
- package/starter-kit/components/Toast/Toast.tsx +144 -0
- package/starter-kit/components/Toast/index.ts +2 -0
- package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +121 -0
- package/starter-kit/components/Tooltip/Tooltip.tsx +195 -0
- package/starter-kit/components/Tooltip/index.ts +2 -0
- package/starter-kit/components/TopNav/TopNav.catalog.tsx +71 -0
- package/starter-kit/components/TopNav/TopNav.tsx +77 -0
- package/starter-kit/components/TopNav/index.ts +2 -0
- package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +30 -0
- package/starter-kit/components/UnderlineTabs/UnderlineTabs.tsx +156 -0
- package/starter-kit/components/UnderlineTabs/index.ts +2 -0
- package/starter-kit/components/index.ts +36 -0
- package/starter-kit/components/statusColors.ts +27 -0
- package/starter-kit/icons/Icon.native.tsx +90 -0
- package/starter-kit/icons/index.ts +10 -0
- package/starter-kit/icons/paths.ts +623 -0
- package/starter-kit/icons/types.ts +69 -0
- package/starter-kit/pages/Colors.catalog.tsx +55 -0
- package/starter-kit/pages/Font.catalog.tsx +45 -0
- package/starter-kit/pages/Icons.catalog.tsx +51 -0
- package/starter-kit/pages/Manifest.catalog.tsx +24 -0
- package/starter-kit/pages/Motion.catalog.tsx +101 -0
- package/starter-kit/pages/Radius.catalog.tsx +35 -0
- package/starter-kit/pages/ReportIssue.catalog.tsx +148 -0
- package/starter-kit/pages/SavedTrips.catalog.tsx +247 -0
- package/starter-kit/pages/Shadow.catalog.tsx +34 -0
- package/starter-kit/pages/Spacing.catalog.tsx +12 -0
- package/starter-kit/pages/TripPlannerForm.catalog.tsx +48 -0
- package/starter-kit/pages/Typography.catalog.tsx +17 -0
- package/starter-kit/tokens/index.ts +50 -0
- package/starter-kit/tokens/motion.ts +90 -0
- package/starter-kit/tokens/palette.ts +111 -0
- package/starter-kit/tokens/scales.ts +106 -0
- package/starter-kit/tokens/semantic.ts +98 -0
- package/starter-kit/tokens/shadow.ts +36 -0
- package/starter-kit/tokens/typography.ts +72 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
// Design §4 "GitHub Action" + §5's lockfile-detection assumption. `checkout`/`setup-node` pins
|
|
4
|
+
// reused verbatim from this repository's own `.github/workflows/ci.yml` (Global Constraints);
|
|
5
|
+
// `pnpm/action-setup` is the one new SHA this plan resolves (`gh api
|
|
6
|
+
// repos/pnpm/action-setup/git/refs/tags/v6.1.0`, dereferenced to its commit). `node-version: '20'`
|
|
7
|
+
// matches the package's own published `engines` floor (`>=20.19.0`): a user's project only ever
|
|
8
|
+
// runs the compiled `dist/cli/main.js` via `npx ds-viewer`, which needs no type-stripping (unlike
|
|
9
|
+
// this repo's own CI, which type-strips its *source* and therefore needs Node 22).
|
|
10
|
+
// Errata #5: `pnpm/action-setup` needs an explicit pnpm version — it has no lockfile to infer one
|
|
11
|
+
// from. Read the project's own `packageManager` field (`"pnpm@9.1.0"`) when present; otherwise
|
|
12
|
+
// default to 10, matching the pin's own major version line.
|
|
13
|
+
function pnpmVersion(projectRoot) {
|
|
14
|
+
const packageJsonPath = path.join(projectRoot, 'package.json');
|
|
15
|
+
if (!existsSync(packageJsonPath))
|
|
16
|
+
return '10';
|
|
17
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
18
|
+
const match = packageJson.packageManager?.match(/^pnpm@(\d+)/);
|
|
19
|
+
return match ? match[1] : '10';
|
|
20
|
+
}
|
|
21
|
+
// Important finding 6 (Fable's review): Yarn 1 (still the common Expo lockfile) has no
|
|
22
|
+
// `--immutable` flag — its equivalent is `--frozen-lockfile`. Yarn Berry needs `corepack enable`
|
|
23
|
+
// first, since `actions/setup-node` ships Yarn 1 on `ubuntu-latest`. A v1 lockfile's own first line
|
|
24
|
+
// is always exactly `# yarn lockfile v1`; anything else (Berry's own header/`__metadata`) is Berry.
|
|
25
|
+
function yarnInstallStep(projectRoot) {
|
|
26
|
+
const firstLine = readFileSync(path.join(projectRoot, 'yarn.lock'), 'utf8').split('\n', 1)[0];
|
|
27
|
+
if (firstLine === '# yarn lockfile v1')
|
|
28
|
+
return ' - run: yarn install --frozen-lockfile\n';
|
|
29
|
+
return ' - run: corepack enable\n - run: yarn install --immutable\n';
|
|
30
|
+
}
|
|
31
|
+
function installStep(projectRoot) {
|
|
32
|
+
if (existsSync(path.join(projectRoot, 'pnpm-lock.yaml'))) {
|
|
33
|
+
return ` - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0\n with:\n version: ${pnpmVersion(projectRoot)}\n - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0\n with:\n node-version: '20'\n - run: pnpm install --frozen-lockfile\n`;
|
|
34
|
+
}
|
|
35
|
+
const setupNode = ` - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0\n with:\n node-version: '20'\n`;
|
|
36
|
+
if (existsSync(path.join(projectRoot, 'yarn.lock')))
|
|
37
|
+
return `${setupNode}${yarnInstallStep(projectRoot)}`;
|
|
38
|
+
// `package-lock.json`, or no lockfile at all (a fresh project before its first commit) — `npm
|
|
39
|
+
// ci` is npm's own documented behavior for the latter: it installs from `package.json` and
|
|
40
|
+
// writes a fresh lockfile, exactly like a plain `npm install` would.
|
|
41
|
+
return `${setupNode} - run: npm ci\n`;
|
|
42
|
+
}
|
|
43
|
+
function workflowContent(projectRoot) {
|
|
44
|
+
return `name: DS Viewer
|
|
45
|
+
|
|
46
|
+
on:
|
|
47
|
+
pull_request:
|
|
48
|
+
|
|
49
|
+
jobs:
|
|
50
|
+
doctor:
|
|
51
|
+
runs-on: ubuntu-latest
|
|
52
|
+
steps:
|
|
53
|
+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
54
|
+
${installStep(projectRoot)} - run: npx ds-viewer doctor --ci
|
|
55
|
+
`;
|
|
56
|
+
}
|
|
57
|
+
/** Design §4 "GitHub Action", §1 Ownership: written once, never overwritten — a user's own edits
|
|
58
|
+
* to this file (or a differently-named workflow they wrote instead) are never touched again. The
|
|
59
|
+
* install step is picked from whichever lockfile is present *right now*, at `init` time — never
|
|
60
|
+
* re-picked later, since this function never runs again once the file exists. */
|
|
61
|
+
export function ensureGithubAction(projectRoot) {
|
|
62
|
+
const workflowPath = path.join(projectRoot, '.github', 'workflows', 'ds-viewer.yml');
|
|
63
|
+
if (existsSync(workflowPath))
|
|
64
|
+
return [];
|
|
65
|
+
mkdirSync(path.dirname(workflowPath), { recursive: true });
|
|
66
|
+
writeFileSync(workflowPath, workflowContent(projectRoot));
|
|
67
|
+
return [workflowPath];
|
|
68
|
+
}
|
package/dist/cli/glob.js
CHANGED
|
@@ -30,6 +30,14 @@ function walk(root, segments, consumed) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
const [segment, ...rest] = segments;
|
|
33
|
+
// `readdirSync` never returns "." or ".." as entries, so without this a config glob that climbs
|
|
34
|
+
// out of its own root (e.g. kit-host's `../starter-kit/components/*/index.ts`, Task 5) would
|
|
35
|
+
// regex-match against real directory entries and silently resolve to nothing. `path.join` below
|
|
36
|
+
// already collapses "." and ".." correctly once they're part of `consumed` — this just lets them
|
|
37
|
+
// reach it instead of being treated as a literal name to search for.
|
|
38
|
+
if (segment === '..' || segment === '.') {
|
|
39
|
+
return walk(root, rest, [...consumed, segment]);
|
|
40
|
+
}
|
|
33
41
|
const dir = path.join(root, ...consumed);
|
|
34
42
|
let entries;
|
|
35
43
|
try {
|
package/dist/cli/init.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
4
5
|
import { preflight } from "./preflight.js";
|
|
5
6
|
import { detectComponentFolders, detectTokenFiles } from "./detect.js";
|
|
7
|
+
import { ensureAgentsFileSection } from "./agentsFile.js";
|
|
8
|
+
import { ensureGithubAction } from "./githubAction.js";
|
|
6
9
|
async function promptConfirm(message) {
|
|
7
10
|
// A closed, non-interactive stdin (CI, piped npx) would reject `question` with an AbortError;
|
|
8
11
|
// say how to proceed instead.
|
|
@@ -15,25 +18,28 @@ async function promptConfirm(message) {
|
|
|
15
18
|
rl.close();
|
|
16
19
|
return /^y(es)?$/i.test(answer.trim());
|
|
17
20
|
}
|
|
18
|
-
/** This package's own
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
|
|
24
|
-
function ownVersion() {
|
|
21
|
+
/** This package's own installed root — needed both for `ownVersion()` below and (new in 0.2) to
|
|
22
|
+
* find the bundled `starter-kit/` to copy from. Walks up from this file's own directory looking
|
|
23
|
+
* for the `package.json` named `@krapwoo/ds-viewer`, rather than a fixed relative path: this file
|
|
24
|
+
* sits one level under the repo root as `cli/init.ts` (how tests run it) but two levels under it
|
|
25
|
+
* once compiled, as `dist/cli/init.js` — a single `../..` would be wrong for one of the two. */
|
|
26
|
+
function packageRoot() {
|
|
25
27
|
let dir = import.meta.dirname;
|
|
26
28
|
for (let depth = 0; depth < 5; depth += 1) {
|
|
27
29
|
const candidate = path.join(dir, 'package.json');
|
|
28
30
|
if (existsSync(candidate)) {
|
|
29
31
|
const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
30
32
|
if (pkg.name === '@krapwoo/ds-viewer')
|
|
31
|
-
return
|
|
33
|
+
return dir;
|
|
32
34
|
}
|
|
33
35
|
dir = path.dirname(dir);
|
|
34
36
|
}
|
|
35
37
|
throw new Error("Could not find @krapwoo/ds-viewer's own package.json to read its version.");
|
|
36
38
|
}
|
|
39
|
+
function ownVersion() {
|
|
40
|
+
const pkg = JSON.parse(readFileSync(path.join(packageRoot(), 'package.json'), 'utf8'));
|
|
41
|
+
return pkg.version;
|
|
42
|
+
}
|
|
37
43
|
/** Detects the existing indent of a JSON file's first indented line, so rewriting it (e.g.
|
|
38
44
|
* `package.json` below) doesn't reformat the user's own indentation style. Falls back to two
|
|
39
45
|
* spaces when nothing indented is found (e.g. a minified `package.json`). */
|
|
@@ -85,6 +91,25 @@ export async function initExistingProject(projectRoot, options = {}) {
|
|
|
85
91
|
written.push(draftPath);
|
|
86
92
|
}
|
|
87
93
|
}
|
|
94
|
+
written.push(...ensureSharedProjectFiles(projectRoot));
|
|
95
|
+
const routerWarnings = componentFolders
|
|
96
|
+
.filter((folder) => folder.relativePath.split(path.sep)[0] === 'app')
|
|
97
|
+
.map((folder) => `Warning: ${folder.relativePath.split(path.sep).join('/')} is inside an Expo Router "app/" directory; its .catalog.tsx file will become a route.`);
|
|
98
|
+
const messages = [
|
|
99
|
+
...warnings.map((issue) => `Warning: ${issue.package} not found. Run: ${issue.installCommand}`),
|
|
100
|
+
...routerWarnings,
|
|
101
|
+
// "with catalog pages", not "with examples": this counts any existing `<Name>.catalog.tsx`,
|
|
102
|
+
// including init's own drafts, which explicitly say "Needs examples" — not real coverage yet.
|
|
103
|
+
`${componentFolders.length} components · ${withExamplesCount} with catalog pages`,
|
|
104
|
+
'Next: npm install, then npm run ds-viewer dev',
|
|
105
|
+
];
|
|
106
|
+
return { messages, written };
|
|
107
|
+
}
|
|
108
|
+
/** Design §2, "Both paths then write what is missing": `ds-viewer.config.ts` is written by each
|
|
109
|
+
* path separately (its contents differ), but the package script, the devDependency entry, the
|
|
110
|
+
* `.gitignore` entry, and (from 0.3) the `AGENTS.md` section are identical either way. */
|
|
111
|
+
function ensureSharedProjectFiles(projectRoot) {
|
|
112
|
+
const written = [];
|
|
88
113
|
const packageJsonPath = path.join(projectRoot, 'package.json');
|
|
89
114
|
const packageJsonRaw = readFileSync(packageJsonPath, 'utf8');
|
|
90
115
|
const packageJson = JSON.parse(packageJsonRaw);
|
|
@@ -106,26 +131,135 @@ export async function initExistingProject(projectRoot, options = {}) {
|
|
|
106
131
|
const gitignorePath = path.join(projectRoot, '.gitignore');
|
|
107
132
|
const gitignoreEntry = '.ds-viewer/';
|
|
108
133
|
const existingGitignore = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
|
|
109
|
-
// `\r\n` line endings leave a trailing `\r` on each split line, which never equals the entry —
|
|
110
|
-
// trim it per line so a CRLF .gitignore is recognized as already having the entry on a rerun.
|
|
111
134
|
if (!existingGitignore.split('\n').some((line) => line.replace(/\r$/, '') === gitignoreEntry)) {
|
|
112
135
|
const separator = existingGitignore === '' || existingGitignore.endsWith('\n') ? '' : '\n';
|
|
113
136
|
writeFileSync(gitignorePath, `${existingGitignore}${separator}${gitignoreEntry}\n`);
|
|
114
137
|
written.push(gitignorePath);
|
|
115
138
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
139
|
+
written.push(...ensureAgentsFileSection(projectRoot));
|
|
140
|
+
written.push(...ensureGithubAction(projectRoot));
|
|
141
|
+
return written;
|
|
142
|
+
}
|
|
143
|
+
const KIT_REQUIRED_PACKAGES = ['react-native-svg', 'react-native-safe-area-context'];
|
|
144
|
+
/** The starter kit's own intended sidebar order (the same list kit-host/ds-viewer.config.ts
|
|
145
|
+
* declares, minus "Viewer" — that group only holds kit-host's own framework-docs pages under
|
|
146
|
+
* `viewer-pages/`, which `init --new` never copies). Written into a fresh project's config so its
|
|
147
|
+
* sidebar isn't left alphabetical (controller end-to-end finding E3). */
|
|
148
|
+
const KIT_GROUP_ORDER = [
|
|
149
|
+
'Actions', 'Surfaces', 'Inputs', 'Controls', 'Selection', 'Feedback', 'Navigation',
|
|
150
|
+
'Overlays', 'Layout', 'Sub-Parts', 'Recipes', 'Tokens', 'Reference',
|
|
151
|
+
];
|
|
152
|
+
/** Default `installer` — a real `npx expo install`, run only for whichever of
|
|
153
|
+
* `KIT_REQUIRED_PACKAGES` the project doesn't already depend on. Test-injected in every unit test
|
|
154
|
+
* (this plan's Global Constraints: never run a real `expo install` in tests). */
|
|
155
|
+
function defaultInstaller(projectRoot, missingPackages) {
|
|
156
|
+
if (missingPackages.length === 0)
|
|
157
|
+
return;
|
|
158
|
+
// Same `shell: process.platform === 'win32'` reasoning as `dev.ts`'s existing `spawn('npx', ...)`
|
|
159
|
+
// call: Windows resolves `npx` via `npx.cmd`, which `execFileSync` only finds through a shell.
|
|
160
|
+
execFileSync('npx', ['expo', 'install', ...missingPackages], { cwd: projectRoot, stdio: 'inherit', shell: process.platform === 'win32' });
|
|
161
|
+
}
|
|
162
|
+
/** Copies every file under `srcDir` into `destDir`, skipping any destination file that already
|
|
163
|
+
* exists (design's "never overwrites user files" rule — Global Constraints) and reporting, not
|
|
164
|
+
* throwing on, one unreadable source file (Error-handling rule). Also reports, rather than
|
|
165
|
+
* throwing on, an unreadable nested folder — the same `readdirSync` call recurses, so wrapping it
|
|
166
|
+
* here covers every depth — so files already copied before it are still returned, and skips a
|
|
167
|
+
* symlinked entry (neither a plain file nor a directory `copyKitIfMissing` should recurse into)
|
|
168
|
+
* instead of copying through it (Minor finding, Fable's implementation review,
|
|
169
|
+
* cli/init.ts:205-224). Returns every path it wrote. */
|
|
170
|
+
export function copyKitIfMissing(srcDir, destDir) {
|
|
171
|
+
const written = [];
|
|
172
|
+
mkdirSync(destDir, { recursive: true });
|
|
173
|
+
let entries;
|
|
174
|
+
try {
|
|
175
|
+
entries = readdirSync(srcDir, { withFileTypes: true });
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
console.warn(`Could not read ${srcDir}: ${error.message}`);
|
|
179
|
+
return written;
|
|
180
|
+
}
|
|
181
|
+
for (const entry of entries) {
|
|
182
|
+
if (entry.isSymbolicLink())
|
|
183
|
+
continue;
|
|
184
|
+
const srcPath = path.join(srcDir, entry.name);
|
|
185
|
+
const destPath = path.join(destDir, entry.name);
|
|
186
|
+
if (entry.isDirectory()) {
|
|
187
|
+
written.push(...copyKitIfMissing(srcPath, destPath));
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (existsSync(destPath))
|
|
191
|
+
continue;
|
|
192
|
+
try {
|
|
193
|
+
copyFileSync(srcPath, destPath);
|
|
194
|
+
written.push(destPath);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
console.warn(`Could not copy ${path.relative(srcDir, srcPath)}: ${error.message}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return written;
|
|
201
|
+
}
|
|
202
|
+
function newProjectConfigContents(kitRoot, starterKitVersion) {
|
|
203
|
+
return `import { defineConfig } from '@krapwoo/ds-viewer/config';
|
|
204
|
+
|
|
205
|
+
export default defineConfig({
|
|
206
|
+
name: 'My App',
|
|
207
|
+
components: ['${kitRoot}/components/*/index.ts'],
|
|
208
|
+
tokens: ['${kitRoot}/tokens/index.ts'],
|
|
209
|
+
pages: ['${kitRoot}/pages/*.catalog.tsx'],
|
|
210
|
+
groupOrder: ${JSON.stringify(KIT_GROUP_ORDER)},
|
|
211
|
+
starterKit: { version: '${starterKitVersion}' },
|
|
212
|
+
});
|
|
213
|
+
`;
|
|
214
|
+
}
|
|
215
|
+
/** The new-project half of `init` (design §2's "new project" column) — existing-project `init` is
|
|
216
|
+
* `initExistingProject`, 0.1 scope. No confirmation prompt here: unlike the existing-project path,
|
|
217
|
+
* there is nothing to detect or confirm — the kit root is the only user choice, and it's a flag. */
|
|
218
|
+
export async function initNewProject(projectRoot, options = {}) {
|
|
219
|
+
const { errors, warnings } = preflight(projectRoot);
|
|
220
|
+
if (errors.length > 0) {
|
|
221
|
+
return { messages: errors.map((issue) => `Missing ${issue.package}. Run: ${issue.installCommand}`), written: [], exitCode: 1 };
|
|
222
|
+
}
|
|
223
|
+
const packageJson = JSON.parse(readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
|
|
224
|
+
const installedPackages = { ...packageJson.dependencies, ...packageJson.devDependencies };
|
|
225
|
+
const missingKitPackages = KIT_REQUIRED_PACKAGES.filter((name) => !installedPackages[name]);
|
|
226
|
+
await (options.installer ?? defaultInstaller)(projectRoot, missingKitPackages);
|
|
227
|
+
const kitRoot = options.kitRoot ?? 'src/ds';
|
|
228
|
+
const written = copyKitIfMissing(path.join(packageRoot(), 'starter-kit'), path.join(projectRoot, kitRoot));
|
|
229
|
+
// Read before `ensureSharedProjectFiles`/the config write below add their own entries — this is
|
|
230
|
+
// only true when `copyKitIfMissing` itself wrote nothing, i.e. a rerun found every kit file
|
|
231
|
+
// already present (controller end-to-end finding E2: a second `init --new` claimed to have
|
|
232
|
+
// copied the kit even though nothing changed on disk).
|
|
233
|
+
const copiedKitFiles = written.length > 0;
|
|
234
|
+
const configPath = path.join(projectRoot, 'ds-viewer.config.ts');
|
|
235
|
+
if (!existsSync(configPath)) {
|
|
236
|
+
writeFileSync(configPath, newProjectConfigContents(kitRoot, ownVersion()));
|
|
237
|
+
written.push(configPath);
|
|
238
|
+
}
|
|
239
|
+
written.push(...ensureSharedProjectFiles(projectRoot));
|
|
119
240
|
const messages = [
|
|
120
241
|
...warnings.map((issue) => `Warning: ${issue.package} not found. Run: ${issue.installCommand}`),
|
|
121
|
-
...
|
|
122
|
-
|
|
123
|
-
// including init's own drafts, which explicitly say "Needs examples" — not real coverage yet.
|
|
124
|
-
`${componentFolders.length} components · ${withExamplesCount} with catalog pages`,
|
|
242
|
+
...(missingKitPackages.length > 0 ? [`Installed ${missingKitPackages.join(', ')}.`] : []),
|
|
243
|
+
copiedKitFiles ? `Copied the starter kit into ${kitRoot}/.` : `Starter kit already present in ${kitRoot}/ — nothing copied.`,
|
|
125
244
|
'Next: npm install, then npm run ds-viewer dev',
|
|
126
245
|
];
|
|
127
246
|
return { messages, written };
|
|
128
247
|
}
|
|
248
|
+
/** The one new-vs-existing question (design §2's "One question") — `--new`/`--existing` (wired in
|
|
249
|
+
* `cli/main.ts`) skip it entirely; this is only reached with neither flag. */
|
|
250
|
+
export async function promptInitMode() {
|
|
251
|
+
if (!process.stdin.isTTY)
|
|
252
|
+
return undefined;
|
|
253
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
254
|
+
const answer = await rl.question('New or existing project? [new/existing] ');
|
|
255
|
+
rl.close();
|
|
256
|
+
const normalized = answer.trim().toLowerCase();
|
|
257
|
+
if (normalized === 'new' || normalized === 'n')
|
|
258
|
+
return 'new';
|
|
259
|
+
if (normalized === 'existing' || normalized === 'e')
|
|
260
|
+
return 'existing';
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
129
263
|
function configFileContents(componentFolders, tokenFiles) {
|
|
130
264
|
// Emits both `index.ts` and `index.tsx` globs for every detected parent folder whenever any
|
|
131
265
|
// detected folder uses `.tsx` (`detect.ts` accepts either extension — Important: a project using
|
package/dist/cli/main.js
CHANGED
|
@@ -3,16 +3,28 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { resolveConfig } from "./config.js";
|
|
6
|
-
import { initExistingProject } from "./init.js";
|
|
6
|
+
import { initExistingProject, initNewProject, promptInitMode } from "./init.js";
|
|
7
7
|
import { dev } from "./dev.js";
|
|
8
8
|
import { sync } from "./sync.js";
|
|
9
|
+
import { formatHuman, runDoctor, toDoctorJson } from "./doctor.js";
|
|
10
|
+
import { explainPage, formatExplain, parseHeights } from "./explain.js";
|
|
9
11
|
const USAGE = `Usage: ds-viewer <command>
|
|
10
12
|
|
|
11
13
|
Commands:
|
|
12
|
-
init Set up the viewer in this project: config,
|
|
13
|
-
--
|
|
14
|
+
init Set up the viewer in this project: config, catalog pages, and an npm script
|
|
15
|
+
--new start from the starter kit (new project)
|
|
16
|
+
--existing detect this project's own components/tokens (existing project)
|
|
17
|
+
--kit-root DIR where to copy the starter kit (--new only; default src/ds)
|
|
18
|
+
--yes accept the detected components and tokens without asking (--existing only)
|
|
14
19
|
sync Regenerate the component, props, token, and page data from source
|
|
15
20
|
dev Sync, then serve the catalog on a local web address and keep it current as files change
|
|
21
|
+
doctor Check every page for drift and coverage gaps
|
|
22
|
+
--json print the machine-readable report instead
|
|
23
|
+
--ci exit 1 when any error was found (never on a warning alone)
|
|
24
|
+
explain <Page>
|
|
25
|
+
Print why a page's specimens are laid out the way they are
|
|
26
|
+
--heights <first>,<second> check side-by-side placement with these measured heights
|
|
27
|
+
--json print the machine-readable report instead
|
|
16
28
|
|
|
17
29
|
Options:
|
|
18
30
|
-h, --help Show this help
|
|
@@ -33,6 +45,90 @@ function packageVersion() {
|
|
|
33
45
|
dir = parent;
|
|
34
46
|
}
|
|
35
47
|
}
|
|
48
|
+
/** `--kit-root`'s raw CLI value, normalized to a project-root-relative, forward-slash path — or an
|
|
49
|
+
* error when the value can't be made into one. Without this: an absolute value was silently
|
|
50
|
+
* relocated *under* the project root by `path.join` (which never does `path.resolve`'s "a later
|
|
51
|
+
* absolute segment replaces everything before it" — it just concatenates), a trailing slash
|
|
52
|
+
* produced a doubled-slash glob (`src/ds//components/*`), and a quote broke the generated
|
|
53
|
+
* config's TypeScript string literal (Minor finding, Fable's implementation review,
|
|
54
|
+
* cli/main.ts:59-60 & cli/init.ts:231-233,257). */
|
|
55
|
+
export function parseKitRoot(projectRoot, rawValue) {
|
|
56
|
+
if (rawValue.includes("'") || rawValue.includes('"')) {
|
|
57
|
+
return { error: `--kit-root cannot contain a quote (got "${rawValue}")` };
|
|
58
|
+
}
|
|
59
|
+
const relative = path.relative(projectRoot, path.resolve(projectRoot, rawValue));
|
|
60
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
61
|
+
return { error: `--kit-root must stay inside the project root (got "${rawValue}")` };
|
|
62
|
+
}
|
|
63
|
+
return { value: relative.split(path.sep).join('/') };
|
|
64
|
+
}
|
|
65
|
+
/** Parses `init`'s flags and dispatches to `initNewProject`/`initExistingProject` — pulled out of
|
|
66
|
+
* `main` so the dispatch (which flags pick which project path, `--yes`'s 0.1-compatible default,
|
|
67
|
+
* `--kit-root`'s normalization) is unit-testable with an injected installer, never a real `expo
|
|
68
|
+
* install` (Minor finding, Fable's implementation review: "No CLI test covers `--yes` defaulting
|
|
69
|
+
* to existing, `--kit-root` parsing, or `--new` dispatch"). */
|
|
70
|
+
export async function runInit(projectRoot, rest, options = {}) {
|
|
71
|
+
const kitRootIndex = rest.indexOf('--kit-root');
|
|
72
|
+
// Validated, not just indexed: a bare trailing `--kit-root` (no value) or `--kit-root --yes`
|
|
73
|
+
// (the next token is itself a flag) would otherwise silently pass `undefined`/`'--yes'` as the
|
|
74
|
+
// kit root. Both are treated as "no value given", the same as omitting the flag entirely.
|
|
75
|
+
const kitRootValue = kitRootIndex !== -1 ? rest[kitRootIndex + 1] : undefined;
|
|
76
|
+
const kitRootRaw = kitRootValue && !kitRootValue.startsWith('--') ? kitRootValue : undefined;
|
|
77
|
+
let kitRoot;
|
|
78
|
+
if (kitRootRaw !== undefined) {
|
|
79
|
+
const parsed = parseKitRoot(projectRoot, kitRootRaw);
|
|
80
|
+
if (parsed.error)
|
|
81
|
+
return { messages: [parsed.error], written: [], exitCode: 1 };
|
|
82
|
+
kitRoot = parsed.value;
|
|
83
|
+
}
|
|
84
|
+
let mode = rest.includes('--new') ? 'new' : rest.includes('--existing') ? 'existing' : undefined;
|
|
85
|
+
// 0.1 had only one `init` path (today's `initExistingProject`) and no mode question at all —
|
|
86
|
+
// `--yes` alone must keep working exactly as it did for a 0.1 user or script, so `--yes` with
|
|
87
|
+
// neither `--new` nor `--existing` defaults to `existing`, the same path 0.1's `--yes` always
|
|
88
|
+
// ran, instead of prompting (which would hang/exit 1 on non-interactive stdin and break them).
|
|
89
|
+
if (!mode && rest.includes('--yes'))
|
|
90
|
+
mode = 'existing';
|
|
91
|
+
if (!mode)
|
|
92
|
+
mode = await (options.promptMode ?? promptInitMode)();
|
|
93
|
+
if (!mode) {
|
|
94
|
+
const isTTY = options.isTTY ?? Boolean(process.stdin.isTTY);
|
|
95
|
+
// A non-interactive stdin (CI, piped npx) never got to ask the question at all — but a TTY
|
|
96
|
+
// whose answer `promptInitMode` simply couldn't parse did ask, so it needs its own message,
|
|
97
|
+
// not the "non-interactive" one (Minor finding, Fable's implementation review).
|
|
98
|
+
const message = isTTY
|
|
99
|
+
? 'Could not parse that answer. Specify --new or --existing directly.'
|
|
100
|
+
: 'Specify --new or --existing (non-interactive stdin cannot be asked).';
|
|
101
|
+
return { messages: [`${message}\n\n${USAGE}`], written: [], exitCode: 1 };
|
|
102
|
+
}
|
|
103
|
+
return mode === 'new'
|
|
104
|
+
? initNewProject(projectRoot, { kitRoot, installer: options.installer })
|
|
105
|
+
: initExistingProject(projectRoot, { yes: rest.includes('--yes'), confirm: options.confirm });
|
|
106
|
+
}
|
|
107
|
+
/** Pulled out of `main()` for the same reason `runInit` was in 0.1/0.2 (Fable's review): testable
|
|
108
|
+
* without spawning a process. `--ci`'s exit code is read from `runDoctor`'s own `exitCode` — a
|
|
109
|
+
* plain `doctor` (no `--ci`) always returns 0 here regardless of errors found. */
|
|
110
|
+
export function runDoctorCommand(projectRoot, rest) {
|
|
111
|
+
const config = resolveConfig(projectRoot);
|
|
112
|
+
const result = runDoctor(config);
|
|
113
|
+
const output = rest.includes('--json') ? JSON.stringify(toDoctorJson(result), null, 2) : formatHuman(result);
|
|
114
|
+
return { output, exitCode: rest.includes('--ci') ? result.exitCode : 0 };
|
|
115
|
+
}
|
|
116
|
+
/** Same "testable without spawning" shape as `runDoctorCommand` above. */
|
|
117
|
+
export function runExplainCommand(projectRoot, pageId, rest) {
|
|
118
|
+
if (!pageId)
|
|
119
|
+
return { output: 'Usage: ds-viewer explain <Page> [--heights <first>,<second>] [--json]', exitCode: 1 };
|
|
120
|
+
const heightsIndex = rest.indexOf('--heights');
|
|
121
|
+
const heightsRaw = heightsIndex !== -1 ? rest[heightsIndex + 1] : undefined;
|
|
122
|
+
const heights = heightsRaw ? parseHeights(heightsRaw) : undefined;
|
|
123
|
+
if (heightsRaw && !heights) {
|
|
124
|
+
return { output: `--heights must be two comma-separated numbers, e.g. --heights 420,610 (got "${heightsRaw}")`, exitCode: 1 };
|
|
125
|
+
}
|
|
126
|
+
const config = resolveConfig(projectRoot);
|
|
127
|
+
const result = explainPage(config, pageId, { heights });
|
|
128
|
+
if (!result)
|
|
129
|
+
return { output: `No page named "${pageId}" was found.`, exitCode: 1 };
|
|
130
|
+
return { output: rest.includes('--json') ? JSON.stringify(result, null, 2) : formatExplain(result), exitCode: 0 };
|
|
131
|
+
}
|
|
36
132
|
async function main() {
|
|
37
133
|
const [command, ...rest] = process.argv.slice(2);
|
|
38
134
|
const projectRoot = process.cwd();
|
|
@@ -45,7 +141,7 @@ async function main() {
|
|
|
45
141
|
return;
|
|
46
142
|
}
|
|
47
143
|
if (command === 'init') {
|
|
48
|
-
const result = await
|
|
144
|
+
const result = await runInit(projectRoot, rest);
|
|
49
145
|
for (const message of result.messages)
|
|
50
146
|
console.log(message);
|
|
51
147
|
for (const file of result.written)
|
|
@@ -60,6 +156,21 @@ async function main() {
|
|
|
60
156
|
console.log(`Synced ${result.componentCount} components, ${result.pageCount} pages.`);
|
|
61
157
|
return;
|
|
62
158
|
}
|
|
159
|
+
if (command === 'doctor') {
|
|
160
|
+
const { output, exitCode } = runDoctorCommand(projectRoot, rest);
|
|
161
|
+
console.log(output);
|
|
162
|
+
if (exitCode)
|
|
163
|
+
process.exitCode = exitCode;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (command === 'explain') {
|
|
167
|
+
const [pageId, ...explainRest] = rest;
|
|
168
|
+
const { output, exitCode } = runExplainCommand(projectRoot, pageId, explainRest);
|
|
169
|
+
console.log(output);
|
|
170
|
+
if (exitCode)
|
|
171
|
+
process.exitCode = exitCode;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
63
174
|
if (command === 'dev') {
|
|
64
175
|
const config = resolveConfig(projectRoot);
|
|
65
176
|
await dev(config);
|
package/dist/cli/pageIndex.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { resolveGlob } from "./glob.js";
|
|
4
4
|
/** Component pages: `<Export>.catalog.tsx` beside the component's entry file (design §2). A
|
|
@@ -10,6 +10,25 @@ export function discoverPages(input) {
|
|
|
10
10
|
const standalonePages = input.standalonePageGlobs.flatMap((pattern) => resolveGlob(input.projectRoot, pattern));
|
|
11
11
|
return [...componentPages, ...standalonePages].sort();
|
|
12
12
|
}
|
|
13
|
+
/** Every `*.catalog.tsx`/`.catalog.ts` file `doctor` should read — unlike `discoverPages`, this
|
|
14
|
+
* also finds a page beside a component whose folder still exists but whose export was renamed or
|
|
15
|
+
* removed (that page's file never changed, so it's still sitting right there). `componentFolders`
|
|
16
|
+
* is every distinct folder containing a currently-resolved component entry file — `cli/doctor.ts`
|
|
17
|
+
* derives it the same way `cli/sync.ts` does, by resolving `config.components` and taking each
|
|
18
|
+
* match's `path.dirname`. A folder that was deleted entirely (not just renamed within) has nothing
|
|
19
|
+
* left to find here, which is correct: there's no stale page file left on disk to warn about. */
|
|
20
|
+
export function discoverAllPageFiles(input) {
|
|
21
|
+
const componentPages = input.componentFolders.flatMap((folder) => {
|
|
22
|
+
try {
|
|
23
|
+
return readdirSync(folder).filter((name) => /\.catalog\.tsx?$/.test(name)).map((name) => path.join(folder, name));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
const standalonePages = input.standalonePageGlobs.flatMap((pattern) => resolveGlob(input.projectRoot, pattern));
|
|
30
|
+
return [...new Set([...componentPages, ...standalonePages])].sort();
|
|
31
|
+
}
|
|
13
32
|
/** Writes `.ds-viewer/generated/pages.ts`: imports every discovered page's default export,
|
|
14
33
|
* resolves each one's id (design §2 "Page id": `id ?? component ?? <file stem>` — a component
|
|
15
34
|
* page defaults to its export name, a standalone page to its own file's stem) and `file` (the
|
|
@@ -25,6 +44,13 @@ export function writePageIndex(generatedDir, pageFiles) {
|
|
|
25
44
|
writeFileSync(outFile, `${importLines.join('\n')}\n\n${resolveLines.join('\n')}\n\n${exportLine}\n`);
|
|
26
45
|
return outFile;
|
|
27
46
|
}
|
|
47
|
+
/** `page.id ?? page.component ?? <file's own stem>` (design §2 "Page id") — factored out of
|
|
48
|
+
* `writePageIndex`'s own inline logic above so `cli/doctor.ts` resolves a page's id exactly the
|
|
49
|
+
* same way, never a second, drifting copy of this rule. Takes the same two fields `StaticPage`
|
|
50
|
+
* (Task 3) and the generated page index both already carry. */
|
|
51
|
+
export function resolvePageId(page, file) {
|
|
52
|
+
return page.id ?? page.component ?? fileStem(file);
|
|
53
|
+
}
|
|
28
54
|
function toImportPath(fromDir, toFile) {
|
|
29
55
|
const withoutExt = toFile.replace(/\.tsx?$/, '');
|
|
30
56
|
const relativePath = path.relative(fromDir, withoutExt).split(path.sep).join('/');
|