@atelier-ui/create-workspace 0.2.41 → 0.2.43
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/CHANGELOG.md +33 -4
- package/package.json +1 -1
- package/src/generators/preset/files/contracts/README.md +29 -0
- package/src/generators/preset/files/contracts/button.contract.ts.template +20 -0
- package/src/generators/preset/files/contracts/types.ts.template +55 -0
- package/src/generators/preset/files/figma/snapshot.json +164 -0
- package/src/generators/preset/files/storybook/angular/atl-button.stories.ts.template +38 -0
- package/src/generators/preset/files/storybook/angular/main.ts.template +39 -0
- package/src/generators/preset/files/storybook/angular/preview.ts.template +30 -0
- package/src/generators/preset/files/storybook/angular/tsconfig.json +16 -0
- package/src/generators/preset/files/storybook/angular/vitest.config.ts.template +40 -0
- package/src/generators/preset/files/storybook/angular/vitest.setup.ts.template +14 -0
- package/src/generators/preset/files/storybook/react/atl-button.stories.tsx +31 -0
- package/src/generators/preset/files/storybook/react/main.ts.template +37 -0
- package/src/generators/preset/files/storybook/react/preview.tsx +29 -0
- package/src/generators/preset/files/storybook/react/vitest.config.ts.template +32 -0
- package/src/generators/preset/files/storybook/react/vitest.setup.ts.template +8 -0
- package/src/generators/preset/files/storybook/vue/atl-button.stories.ts.template +34 -0
- package/src/generators/preset/files/storybook/vue/main.ts.template +38 -0
- package/src/generators/preset/files/storybook/vue/preview.ts.template +29 -0
- package/src/generators/preset/files/storybook/vue/vitest.config.ts.template +32 -0
- package/src/generators/preset/files/storybook/vue/vitest.setup.ts.template +9 -0
- package/src/generators/preset/files/styles/tokens.css +59 -43
- package/src/generators/preset/files/tools/scripts/check-contracts.mjs +1644 -0
- package/src/generators/preset/files/tools/scripts/figma-snapshot-contracts.mjs +370 -0
- package/src/generators/preset/files/tools/scripts/lib/docgen.mjs +573 -0
- package/src/generators/preset/files/tools/scripts/lib/ts-eval.js +126 -0
- package/src/generators/preset/files/tools/scripts/preflight.mjs +220 -30
- package/src/generators/preset/files/tools/stylelint-rules/index.js +21 -0
- package/src/generators/preset/files/tools/stylelint-rules/no-primitive-token.js +362 -0
- package/src/generators/preset/files/tools/stylelint-rules/no-raw-color-literal.js +154 -0
- package/src/generators/preset/files/tools/stylelint-rules/no-token-bypass.js +497 -0
- package/src/generators/preset/files/tools/stylelint-rules/no-undeclared-token.js +122 -0
- package/src/generators/preset/files/tools/stylelint-rules/utils.js +71 -0
- package/src/generators/preset/preset.d.ts +1 -0
- package/src/generators/preset/preset.js +955 -5
- package/src/generators/preset/preset.js.map +1 -1
- package/src/generators/preset/schema.d.ts +1 -0
- package/src/generators/preset/schema.json +5 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared docgen plumbing (ADR-0121 §5 / S6(a)).
|
|
3
|
+
*
|
|
4
|
+
* Extracted out of check-contracts.mjs so check-manifest-parity.mjs can reuse the
|
|
5
|
+
* exact same recipe rather than re-deriving it: the framework-worker docgen call
|
|
6
|
+
* for Angular/Vue (`@storybook/{angular-vite,vue3}/internal/docgen-worker`, a
|
|
7
|
+
* story file as the entry point), `react-docgen`'s own `parse()` for React (the
|
|
8
|
+
* worker's React export is the inactive react-component-meta engine in this
|
|
9
|
+
* repo), and each framework's raw-payload normaliser into the common
|
|
10
|
+
* `{ name, kind: 'enum'|'boolean'|'other', members, default, isOutput }` shape
|
|
11
|
+
* check-contracts.mjs already established. Both scripts importing from one place
|
|
12
|
+
* means they cannot silently diverge on what a framework's docgen payload means
|
|
13
|
+
* — the exact drift class this repo's gates exist to prevent elsewhere.
|
|
14
|
+
*
|
|
15
|
+
* Every function here is parameterized (root, cwdRequire) rather than reading
|
|
16
|
+
* module-level globals, so it works identically whichever script imports it and
|
|
17
|
+
* from whatever cwd that script resolves its own framework packages.
|
|
18
|
+
*/
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { pathToFileURL } from 'node:url';
|
|
24
|
+
|
|
25
|
+
export const FRAMEWORKS = ['angular', 'react', 'vue'];
|
|
26
|
+
|
|
27
|
+
/** Recursively collect `**\/*.stories.{ts,tsx}` under `dir`, excluding `node_modules`. */
|
|
28
|
+
export function collectStoryFilesUnder(dir) {
|
|
29
|
+
const out = [];
|
|
30
|
+
if (!fs.existsSync(dir)) return out;
|
|
31
|
+
(function walk(d) {
|
|
32
|
+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
33
|
+
if (entry.name === 'node_modules') continue;
|
|
34
|
+
const full = path.join(d, entry.name);
|
|
35
|
+
if (entry.isDirectory()) walk(full);
|
|
36
|
+
else if (entry.isFile() && /\.stories\.(ts|tsx)$/.test(entry.name))
|
|
37
|
+
out.push(full);
|
|
38
|
+
}
|
|
39
|
+
})(dir);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Story files for `fw`: every `storiesDirs` root walked recursively when given
|
|
45
|
+
* (a scaffold's one roster for its one framework), else the monorepo's
|
|
46
|
+
* `libs/<fw>/src/lib` convention under `root`.
|
|
47
|
+
*/
|
|
48
|
+
export function findStoryFiles(fw, { root, storiesDirs } = {}) {
|
|
49
|
+
if (storiesDirs) {
|
|
50
|
+
const out = new Set();
|
|
51
|
+
for (const dir of storiesDirs)
|
|
52
|
+
for (const f of collectStoryFilesUnder(dir)) out.add(f);
|
|
53
|
+
return [...out].sort();
|
|
54
|
+
}
|
|
55
|
+
const base = path.join(root, 'libs', fw, 'src', 'lib');
|
|
56
|
+
return collectStoryFilesUnder(base).sort();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function toRepoImportPath(absPath, root) {
|
|
60
|
+
return './' + path.relative(root, absPath).split(path.sep).join('/');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Best-effort human-readable message for a caught value that might not be an
|
|
64
|
+
* `Error` (a thrown string, a plain object, ...). Shared so a [DOCGEN-FAILED]
|
|
65
|
+
* reason reads consistently across every catch site that reports one: this
|
|
66
|
+
* file's own `makeWorkerDocgen()`, and check-contracts.mjs's / check-
|
|
67
|
+
* manifest-parity.mjs's own try/catch around `reactParseFile()` (react-docgen
|
|
68
|
+
* isn't guaranteed to throw an `Error` instance either). */
|
|
69
|
+
export function errorMessage(e) {
|
|
70
|
+
return e && e.message ? e.message : String(e);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether `rawSpecifier` — a story meta's `component:` import path, csf-tools'
|
|
75
|
+
* `_rawComponentPath` (set for ANY framework whose meta resolves `component` to
|
|
76
|
+
* a statically-imported identifier, not just React) — is a BARE package
|
|
77
|
+
* specifier (`@scope/name[/sub]` or `name[/sub]`, never `./`, `../` or `/`)
|
|
78
|
+
* that a real install answers, walked up from `storyFile`'s own directory the
|
|
79
|
+
* same way Node's own `node_modules` resolution does (parent directories,
|
|
80
|
+
* `node_modules/<pkg>/package.json` at each). Returns that `node_modules/<pkg>`
|
|
81
|
+
* directory, or `null` when `rawSpecifier` is missing/relative/absolute, when
|
|
82
|
+
* the walk finds no matching install (e.g. a workspace tsconfig path alias
|
|
83
|
+
* like this monorepo's own `@atelier-ui/*`, which has no real `node_modules`
|
|
84
|
+
* entry at all), or when the matching `node_modules/<pkg>` entry is an
|
|
85
|
+
* npm-workspaces SYMLINK back into the repo's own source (e.g. this repo's own
|
|
86
|
+
* `node_modules/@atelier-ui/generators -> tools/generators`) — resolved via
|
|
87
|
+
* `fs.realpathSync` and classed as external only when the REAL path still
|
|
88
|
+
* contains a `node_modules` path segment; a workspace symlink's real path
|
|
89
|
+
* doesn't, so it's workspace code and must be docgen'd, not skipped.
|
|
90
|
+
*
|
|
91
|
+
* Deliberately NOT `require.resolve`: a package's `exports` map makes that
|
|
92
|
+
* throw for a subpath it doesn't list and for an ESM-only package resolved
|
|
93
|
+
* through a CJS `createRequire` — exactly the packages this check exists to
|
|
94
|
+
* catch (`@atelier-ui/angular` et al. in a real scaffold install). The
|
|
95
|
+
* directory walk sidesteps both failure modes because it only needs to know
|
|
96
|
+
* the package is INSTALLED, not import anything from it.
|
|
97
|
+
*/
|
|
98
|
+
export function findExternalPackageDir(storyFile, rawSpecifier) {
|
|
99
|
+
if (!rawSpecifier) return null;
|
|
100
|
+
if (
|
|
101
|
+
rawSpecifier.startsWith('./') ||
|
|
102
|
+
rawSpecifier.startsWith('../') ||
|
|
103
|
+
rawSpecifier.startsWith('/')
|
|
104
|
+
)
|
|
105
|
+
return null;
|
|
106
|
+
|
|
107
|
+
const segments = rawSpecifier.split('/');
|
|
108
|
+
const pkg = rawSpecifier.startsWith('@')
|
|
109
|
+
? segments.slice(0, 2).join('/')
|
|
110
|
+
: segments[0];
|
|
111
|
+
if (!pkg) return null;
|
|
112
|
+
|
|
113
|
+
let dir = path.dirname(storyFile);
|
|
114
|
+
for (;;) {
|
|
115
|
+
const candidate = path.join(dir, 'node_modules', pkg);
|
|
116
|
+
if (fs.existsSync(path.join(candidate, 'package.json'))) {
|
|
117
|
+
let real;
|
|
118
|
+
try {
|
|
119
|
+
real = fs.realpathSync(candidate);
|
|
120
|
+
} catch {
|
|
121
|
+
real = candidate;
|
|
122
|
+
}
|
|
123
|
+
return real.includes(`${path.sep}node_modules${path.sep}`)
|
|
124
|
+
? candidate
|
|
125
|
+
: null;
|
|
126
|
+
}
|
|
127
|
+
const parent = path.dirname(dir);
|
|
128
|
+
if (parent === dir) return null;
|
|
129
|
+
dir = parent;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** First of `base`, `base.tsx`, `base.ts`, `base/index.tsx`, `base/index.ts` that exists as a file. */
|
|
134
|
+
export function resolveWithExtensions(base) {
|
|
135
|
+
const candidates = [
|
|
136
|
+
base,
|
|
137
|
+
`${base}.tsx`,
|
|
138
|
+
`${base}.ts`,
|
|
139
|
+
path.join(base, 'index.tsx'),
|
|
140
|
+
path.join(base, 'index.ts'),
|
|
141
|
+
];
|
|
142
|
+
for (const c of candidates) {
|
|
143
|
+
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Angular/Vue docgen via the Storybook framework worker, story file as the
|
|
150
|
+
* entry point. The worker package itself is resolved from `cwdRequire`'s
|
|
151
|
+
* node_modules (the caller's cwd), not this file's own directory, so a copy
|
|
152
|
+
* dropped into a scaffold picks up the scaffold's own installed Storybook —
|
|
153
|
+
* same portability rule check-contracts.mjs already followed.
|
|
154
|
+
*
|
|
155
|
+
* The returned function resolves to a DISCRIMINATED result rather than a bare
|
|
156
|
+
* payload-or-null: `{ ok: true, payload }` on success, `{ ok: false, reason }`
|
|
157
|
+
* for every failure mode — the provider throwing, an empty/falsy payload, or
|
|
158
|
+
* `payload.error` being set. Collapsing all three into `null` (the shape this
|
|
159
|
+
* function had until ADR-0124) is exactly what let a broken docgen worker
|
|
160
|
+
* read as "no component here": both call sites treated `null` as "skip,
|
|
161
|
+
* nothing to see" with no residual signal at all (ADR-0034's
|
|
162
|
+
* roster-derivation convention, applied to this gate by ADR-0124). `reason`
|
|
163
|
+
* is always a string, ready to drop straight into a finding message.
|
|
164
|
+
*/
|
|
165
|
+
export async function makeWorkerDocgen(fw, cwdRequire, root) {
|
|
166
|
+
const spec =
|
|
167
|
+
fw === 'angular'
|
|
168
|
+
? '@storybook/angular-vite/internal/docgen-worker'
|
|
169
|
+
: '@storybook/vue3/internal/docgen-worker';
|
|
170
|
+
const mod = await import(pathToFileURL(cwdRequire.resolve(spec)).href);
|
|
171
|
+
const middleware =
|
|
172
|
+
fw === 'angular'
|
|
173
|
+
? mod.createDocgenProvider({ propsTable: 'api' })
|
|
174
|
+
: mod.createDocgenProvider();
|
|
175
|
+
const provider = middleware(async () => undefined);
|
|
176
|
+
return async (storyFilePath, csf) => {
|
|
177
|
+
const entry = {
|
|
178
|
+
type: 'story',
|
|
179
|
+
subtype: 'story',
|
|
180
|
+
id: `${path.basename(storyFilePath)}--docgen`,
|
|
181
|
+
name: 'Docgen',
|
|
182
|
+
title: csf._meta.title,
|
|
183
|
+
importPath: toRepoImportPath(storyFilePath, root),
|
|
184
|
+
tags: [],
|
|
185
|
+
};
|
|
186
|
+
let payload;
|
|
187
|
+
try {
|
|
188
|
+
payload = await provider({ entry });
|
|
189
|
+
} catch (e) {
|
|
190
|
+
return { ok: false, reason: errorMessage(e) };
|
|
191
|
+
}
|
|
192
|
+
if (!payload) return { ok: false, reason: 'empty payload' };
|
|
193
|
+
if (payload.error)
|
|
194
|
+
return { ok: false, reason: errorMessage(payload.error) };
|
|
195
|
+
return { ok: true, payload };
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Angular worker payload -> `{ props, description, slots }`, `props` in the common shape. */
|
|
200
|
+
export function normalizeAngular(payload) {
|
|
201
|
+
const out = [];
|
|
202
|
+
for (const [key, at] of Object.entries(payload.argTypes || {})) {
|
|
203
|
+
const category = at.table && at.table.category;
|
|
204
|
+
if (category !== 'inputs' && category !== 'outputs') continue;
|
|
205
|
+
const isOutput = category === 'outputs';
|
|
206
|
+
let kind = 'other';
|
|
207
|
+
let members;
|
|
208
|
+
const typeName = at.type && at.type.name;
|
|
209
|
+
if (typeName === 'enum' && Array.isArray(at.type.value)) {
|
|
210
|
+
kind = 'enum';
|
|
211
|
+
members = at.type.value;
|
|
212
|
+
} else if (typeName === 'boolean') {
|
|
213
|
+
kind = 'boolean';
|
|
214
|
+
}
|
|
215
|
+
out.push({
|
|
216
|
+
name: key,
|
|
217
|
+
kind,
|
|
218
|
+
members,
|
|
219
|
+
default:
|
|
220
|
+
at.table && at.table.defaultValue
|
|
221
|
+
? at.table.defaultValue.summary
|
|
222
|
+
: undefined,
|
|
223
|
+
description: at.description,
|
|
224
|
+
required: undefined,
|
|
225
|
+
isOutput,
|
|
226
|
+
typeText: at.table && at.table.type ? at.table.type.summary : undefined,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return { props: out, description: payload.description, slots: undefined };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Vue worker payload -> `{ props, description, slots }`, `props` in the common shape (events appended, isOutput: true). */
|
|
233
|
+
export function normalizeVue(payload) {
|
|
234
|
+
const out = [];
|
|
235
|
+
const meta = payload.vueComponentMeta;
|
|
236
|
+
for (const p of (meta && meta.props) || []) {
|
|
237
|
+
if (p.global) continue;
|
|
238
|
+
let kind = 'other';
|
|
239
|
+
let members;
|
|
240
|
+
const schema = p.schema;
|
|
241
|
+
if (schema && schema.kind === 'enum' && Array.isArray(schema.schema)) {
|
|
242
|
+
const raw = schema.schema.filter((v) => v !== 'undefined');
|
|
243
|
+
const isBoolSet =
|
|
244
|
+
raw.length > 0 && raw.every((v) => v === 'true' || v === 'false');
|
|
245
|
+
const isStringEnum =
|
|
246
|
+
raw.length > 0 &&
|
|
247
|
+
raw.every(
|
|
248
|
+
(v) => typeof v === 'string' && v.startsWith('"') && v.endsWith('"'),
|
|
249
|
+
);
|
|
250
|
+
if (isBoolSet) kind = 'boolean';
|
|
251
|
+
else if (isStringEnum) {
|
|
252
|
+
kind = 'enum';
|
|
253
|
+
members = raw.map((v) => v.slice(1, -1));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
let defaultValue;
|
|
257
|
+
if (p.default !== undefined) {
|
|
258
|
+
try {
|
|
259
|
+
defaultValue = JSON.parse(p.default);
|
|
260
|
+
} catch {
|
|
261
|
+
defaultValue = p.default;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
out.push({
|
|
265
|
+
name: p.name,
|
|
266
|
+
kind,
|
|
267
|
+
members,
|
|
268
|
+
default: defaultValue,
|
|
269
|
+
description: p.description || undefined,
|
|
270
|
+
required: p.required,
|
|
271
|
+
isOutput: false,
|
|
272
|
+
typeText: p.type,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
for (const e of (meta && meta.events) || []) {
|
|
276
|
+
out.push({
|
|
277
|
+
name: e.name,
|
|
278
|
+
kind: 'other',
|
|
279
|
+
description: e.description || undefined,
|
|
280
|
+
isOutput: true,
|
|
281
|
+
typeText: e.type,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
const slots = ((meta && meta.slots) || []).map((s) => ({
|
|
285
|
+
name: s.name,
|
|
286
|
+
description: s.description || undefined,
|
|
287
|
+
}));
|
|
288
|
+
return { props: out, description: payload.description, slots };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* React docgen tooling bound to one `cwdRequire`. `react-docgen` is required
|
|
293
|
+
* lazily and from the caller's cwd (an Angular/Vue-only scaffold never installs
|
|
294
|
+
* it), mirroring check-contracts.mjs's original lazy `getReactDocgen()`.
|
|
295
|
+
*/
|
|
296
|
+
export function makeReactDocgenTools(cwdRequire) {
|
|
297
|
+
let _reactDocgen = null;
|
|
298
|
+
function getReactDocgen() {
|
|
299
|
+
if (!_reactDocgen) _reactDocgen = cwdRequire('react-docgen');
|
|
300
|
+
return _reactDocgen;
|
|
301
|
+
}
|
|
302
|
+
function makeReactImporter() {
|
|
303
|
+
const { makeFsImporter } = getReactDocgen();
|
|
304
|
+
return makeFsImporter((filename, basedir) => {
|
|
305
|
+
if (!filename.startsWith('.'))
|
|
306
|
+
throw new Error(`non-relative import '${filename}'`);
|
|
307
|
+
const resolved = resolveWithExtensions(path.resolve(basedir, filename));
|
|
308
|
+
if (!resolved)
|
|
309
|
+
throw new Error(`cannot resolve '${filename}' from '${basedir}'`);
|
|
310
|
+
return resolved;
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
function reactParseFile(filePath) {
|
|
314
|
+
const {
|
|
315
|
+
parse: rdParse,
|
|
316
|
+
builtinResolvers,
|
|
317
|
+
defaultHandlers,
|
|
318
|
+
} = getReactDocgen();
|
|
319
|
+
const code = fs.readFileSync(filePath, 'utf-8');
|
|
320
|
+
const resolver = new builtinResolvers.FindExportedDefinitionsResolver();
|
|
321
|
+
const importer = makeReactImporter();
|
|
322
|
+
const results = rdParse(code, {
|
|
323
|
+
resolver,
|
|
324
|
+
handlers: defaultHandlers,
|
|
325
|
+
importer,
|
|
326
|
+
filename: filePath,
|
|
327
|
+
});
|
|
328
|
+
// Stash the raw source (and its path) on each result — normalizeReactDocgen's
|
|
329
|
+
// destructuring fallback below needs both, and this is the one place a
|
|
330
|
+
// component's file is actually read, so they are attached here rather than
|
|
331
|
+
// re-read per component.
|
|
332
|
+
for (const r of results) {
|
|
333
|
+
r.__source = code;
|
|
334
|
+
r.__file = filePath;
|
|
335
|
+
}
|
|
336
|
+
return results;
|
|
337
|
+
}
|
|
338
|
+
return { getReactDocgen, makeReactImporter, reactParseFile };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Walk up from `startDir` looking for `libs/spec/src/index.ts` — the
|
|
343
|
+
* framework-agnostic contract every `Atl<X>Spec` extends. Bounded (10 levels)
|
|
344
|
+
* and returns `null` rather than throwing when nothing is found, so a
|
|
345
|
+
* standalone scaffold that doesn't carry `libs/spec` at all (see this file's
|
|
346
|
+
* own "parameterized, no module globals" header note) degrades to no
|
|
347
|
+
* candidates instead of an error.
|
|
348
|
+
*/
|
|
349
|
+
function findSpecIndexFile(startDir) {
|
|
350
|
+
let dir = startDir;
|
|
351
|
+
for (let i = 0; i < 10; i++) {
|
|
352
|
+
const candidate = path.join(dir, 'libs', 'spec', 'src', 'index.ts');
|
|
353
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
354
|
+
const parent = path.dirname(dir);
|
|
355
|
+
if (parent === dir) break;
|
|
356
|
+
dir = parent;
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const _formFieldStringPropsCache = new Map(); // specFile -> string[]
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The string-typed field names declared on `AtlFormFieldSpec` in
|
|
365
|
+
* `libs/spec/src/index.ts` (found by walking up from `componentFilePath`) —
|
|
366
|
+
* `['name']` today (`value` is `any`, `onValueChange` a callback, `disabled`/
|
|
367
|
+
* `invalid`/`required` boolean). This is the ONLY candidate set the
|
|
368
|
+
* destructuring fallback below considers: it is the exact, narrow class of
|
|
369
|
+
* prop this fallback exists for (a string field inherited two levels deep
|
|
370
|
+
* through `Atl<X>Spec extends Omit<AtlFormFieldSpec, …>`, with no default for
|
|
371
|
+
* react-docgen's own heuristic to latch onto) — never a general "every
|
|
372
|
+
* destructured identifier react-docgen missed" scan, which would just as
|
|
373
|
+
* happily "recover" a native HTML passthrough attribute (`id`, `style`,
|
|
374
|
+
* `onChange`, …) a component destructures for its own unrelated reasons.
|
|
375
|
+
*/
|
|
376
|
+
function formFieldStringProps(componentFilePath) {
|
|
377
|
+
const specFile = findSpecIndexFile(path.dirname(componentFilePath));
|
|
378
|
+
if (!specFile) return [];
|
|
379
|
+
if (_formFieldStringPropsCache.has(specFile))
|
|
380
|
+
return _formFieldStringPropsCache.get(specFile);
|
|
381
|
+
const src = fs.readFileSync(specFile, 'utf-8');
|
|
382
|
+
const ifaceMatch = /interface\s+AtlFormFieldSpec\s*\{([\s\S]*?)\n\}/.exec(
|
|
383
|
+
src,
|
|
384
|
+
);
|
|
385
|
+
const out = [];
|
|
386
|
+
if (ifaceMatch) {
|
|
387
|
+
const fieldRe = /^\s*(\w+)\??:\s*(.+?);\s*$/gm;
|
|
388
|
+
let fm;
|
|
389
|
+
while ((fm = fieldRe.exec(ifaceMatch[1])) !== null) {
|
|
390
|
+
if (fm[2].trim() === 'string') out.push(fm[1]);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
_formFieldStringPropsCache.set(specFile, out);
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Best-effort scan of `componentName`'s OWN function declaration in `source` for
|
|
399
|
+
* a destructured props parameter (`export function Name({ a, b = false, ... }:
|
|
400
|
+
* Props) {`), returning `{ name, default }` for every top-level, non-rest
|
|
401
|
+
* identifier found there. `default` is populated only when the destructuring
|
|
402
|
+
* default is a bare string/boolean/number literal — an array, object or call
|
|
403
|
+
* expression (`errors = []`) is left `undefined` rather than guessed at.
|
|
404
|
+
*
|
|
405
|
+
* Narrow on purpose (see normalizeReactDocgen's fallback below for why this
|
|
406
|
+
* exists at all): it never inspects a TYPE, only the parameter list actually
|
|
407
|
+
* written in the function signature, so it can't invent a prop that isn't
|
|
408
|
+
* really destructured there. A renamed entry (`name: local`) still yields the
|
|
409
|
+
* PROP name (`name`), not the local binding.
|
|
410
|
+
*/
|
|
411
|
+
function scanDestructuredReactProps(source, componentName) {
|
|
412
|
+
const declRe = new RegExp(`function\\s+${componentName}\\s*\\(`);
|
|
413
|
+
const declMatch = declRe.exec(source);
|
|
414
|
+
if (!declMatch) return [];
|
|
415
|
+
|
|
416
|
+
let i = declMatch.index + declMatch[0].length;
|
|
417
|
+
while (i < source.length && /\s/.test(source[i])) i++;
|
|
418
|
+
if (source[i] !== '{') return []; // not a destructured-object parameter
|
|
419
|
+
|
|
420
|
+
const start = i + 1;
|
|
421
|
+
let depth = 1;
|
|
422
|
+
let j = start;
|
|
423
|
+
for (; j < source.length && depth > 0; j++) {
|
|
424
|
+
if (source[j] === '{') depth++;
|
|
425
|
+
else if (source[j] === '}') depth--;
|
|
426
|
+
}
|
|
427
|
+
if (depth !== 0) return []; // unbalanced — bail rather than guess
|
|
428
|
+
const body = source.slice(start, j - 1);
|
|
429
|
+
|
|
430
|
+
// Split top-level entries on commas, respecting nested {}/[]/() so a default
|
|
431
|
+
// like `errors = []` or a nested destructure doesn't get split mid-token.
|
|
432
|
+
const items = [];
|
|
433
|
+
let cur = '';
|
|
434
|
+
let nestDepth = 0;
|
|
435
|
+
for (const ch of body) {
|
|
436
|
+
if (ch === '{' || ch === '[' || ch === '(') nestDepth++;
|
|
437
|
+
else if (ch === '}' || ch === ']' || ch === ')') nestDepth--;
|
|
438
|
+
if (ch === ',' && nestDepth === 0) {
|
|
439
|
+
items.push(cur);
|
|
440
|
+
cur = '';
|
|
441
|
+
} else {
|
|
442
|
+
cur += ch;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (cur.trim()) items.push(cur);
|
|
446
|
+
|
|
447
|
+
const out = [];
|
|
448
|
+
const itemRe =
|
|
449
|
+
/^([A-Za-z_$][\w$]*)\s*(?::\s*[A-Za-z_$][\w$]*)?\s*(?:=\s*([\s\S]+))?$/;
|
|
450
|
+
for (const raw of items) {
|
|
451
|
+
const item = raw.trim();
|
|
452
|
+
if (!item || item.startsWith('...')) continue;
|
|
453
|
+
const m = itemRe.exec(item);
|
|
454
|
+
if (!m) continue;
|
|
455
|
+
const name = m[1];
|
|
456
|
+
const defaultExpr = m[2] ? m[2].trim() : undefined;
|
|
457
|
+
let literalDefault;
|
|
458
|
+
if (defaultExpr !== undefined) {
|
|
459
|
+
const quoted = /^(['"])([\s\S]*)\1$/.exec(defaultExpr);
|
|
460
|
+
if (quoted) literalDefault = quoted[2];
|
|
461
|
+
else if (defaultExpr === 'true') literalDefault = true;
|
|
462
|
+
else if (defaultExpr === 'false') literalDefault = false;
|
|
463
|
+
else if (/^-?\d+(\.\d+)?$/.test(defaultExpr))
|
|
464
|
+
literalDefault = Number(defaultExpr);
|
|
465
|
+
// anything else (array/object/call expression) — left undefined
|
|
466
|
+
}
|
|
467
|
+
out.push({ name, default: literalDefault });
|
|
468
|
+
}
|
|
469
|
+
return out;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** react-docgen's raw per-component payload -> array of props in the common shape. */
|
|
473
|
+
export function normalizeReactDocgen(d) {
|
|
474
|
+
const out = [];
|
|
475
|
+
const known = new Set();
|
|
476
|
+
for (const [propName, info] of Object.entries(d.props || {})) {
|
|
477
|
+
known.add(propName);
|
|
478
|
+
const tsType = info.tsType;
|
|
479
|
+
let kind = 'other';
|
|
480
|
+
let members;
|
|
481
|
+
if (tsType) {
|
|
482
|
+
if (
|
|
483
|
+
tsType.name === 'union' &&
|
|
484
|
+
Array.isArray(tsType.elements) &&
|
|
485
|
+
tsType.elements.every((e) => e.name === 'literal')
|
|
486
|
+
) {
|
|
487
|
+
const raw = tsType.elements.map((e) =>
|
|
488
|
+
typeof e.value === 'string'
|
|
489
|
+
? e.value.replace(/^['"]|['"]$/g, '')
|
|
490
|
+
: e.value,
|
|
491
|
+
);
|
|
492
|
+
if (raw.every((v) => v === 'true' || v === 'false')) kind = 'boolean';
|
|
493
|
+
else {
|
|
494
|
+
kind = 'enum';
|
|
495
|
+
members = raw;
|
|
496
|
+
}
|
|
497
|
+
} else if (tsType.name === 'boolean') {
|
|
498
|
+
kind = 'boolean';
|
|
499
|
+
}
|
|
500
|
+
} else if (
|
|
501
|
+
info.defaultValue &&
|
|
502
|
+
(info.defaultValue.value === 'true' ||
|
|
503
|
+
info.defaultValue.value === 'false')
|
|
504
|
+
) {
|
|
505
|
+
// react-docgen sometimes resolves a prop inherited through a multi-level interface
|
|
506
|
+
// chain (AtlCheckboxSpec extends AtlFormFieldSpec) without a tsType at all — seen
|
|
507
|
+
// on 'disabled'/'required' here, though the sibling 'invalid' (declared one level
|
|
508
|
+
// shallower) resolves fine. A literal true/false default is otherwise only ever a
|
|
509
|
+
// boolean prop in this codebase, so infer the kind from it rather than losing the
|
|
510
|
+
// prop to 'other' and under-reporting a real BOOLEAN drift.
|
|
511
|
+
kind = 'boolean';
|
|
512
|
+
}
|
|
513
|
+
let defaultValue;
|
|
514
|
+
if (info.defaultValue && !info.defaultValue.computed) {
|
|
515
|
+
const raw = info.defaultValue.value;
|
|
516
|
+
if (raw === 'true') defaultValue = true;
|
|
517
|
+
else if (raw === 'false') defaultValue = false;
|
|
518
|
+
else if (typeof raw === 'string')
|
|
519
|
+
defaultValue = raw.replace(/^['"]|['"]$/g, '');
|
|
520
|
+
}
|
|
521
|
+
out.push({
|
|
522
|
+
name: propName,
|
|
523
|
+
kind,
|
|
524
|
+
members,
|
|
525
|
+
default: defaultValue,
|
|
526
|
+
description: info.description || undefined,
|
|
527
|
+
required: !!info.required,
|
|
528
|
+
isOutput: /^on[A-Z]/.test(propName),
|
|
529
|
+
typeText: (tsType && (tsType.raw || tsType.name)) || undefined,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
// Fallback for a prop react-docgen resolves NOTHING for at all: it can only
|
|
533
|
+
// infer a boolean kind (above) from a literal true/false DEFAULT VALUE it
|
|
534
|
+
// already found, which requires it to have returned an entry in the first
|
|
535
|
+
// place — that happens for 'disabled'/'required' here (inherited two levels
|
|
536
|
+
// deep through `AtlCheckboxSpec extends Omit<AtlFormFieldSpec, …>`, same as
|
|
537
|
+
// 'name', but destructured WITH a `= false` default for react-docgen's
|
|
538
|
+
// resolver to latch onto). A same-depth prop with no default at all (`name`,
|
|
539
|
+
// no default) gives react-docgen nothing to find, so it is missing from
|
|
540
|
+
// `d.props` entirely rather than merely untyped. Recovered from the
|
|
541
|
+
// component's own destructured props parameter — same regex-over-the-source
|
|
542
|
+
// idiom check-defaults.js/check-contracts.mjs use — but ONLY for the exact,
|
|
543
|
+
// narrow candidate set formFieldStringProps() names (see its own doc comment
|
|
544
|
+
// for why: this is not a general "every destructured identifier react-docgen
|
|
545
|
+
// missed" scan, which would just as happily manufacture a finding for a
|
|
546
|
+
// native HTML passthrough attribute — `id`, `style`, `onChange`, … — that a
|
|
547
|
+
// component destructures for its own, unrelated reasons and that react-docgen
|
|
548
|
+
// fails to resolve for a completely different reason (a generic DOM type
|
|
549
|
+
// react-docgen's resolver can't traverse at all, not this two-level-Omit
|
|
550
|
+
// shape).
|
|
551
|
+
if (d.__source && d.displayName && d.__file) {
|
|
552
|
+
const candidates = new Set(formFieldStringProps(d.__file));
|
|
553
|
+
if (candidates.size > 0) {
|
|
554
|
+
for (const {
|
|
555
|
+
name,
|
|
556
|
+
default: literalDefault,
|
|
557
|
+
} of scanDestructuredReactProps(d.__source, d.displayName)) {
|
|
558
|
+
if (known.has(name) || !candidates.has(name)) continue;
|
|
559
|
+
out.push({
|
|
560
|
+
name,
|
|
561
|
+
kind: 'other',
|
|
562
|
+
members: undefined,
|
|
563
|
+
default: literalDefault,
|
|
564
|
+
description: undefined,
|
|
565
|
+
required: false,
|
|
566
|
+
isOutput: /^on[A-Z]/.test(name),
|
|
567
|
+
typeText: undefined,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return out;
|
|
573
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Shared TypeScript Compiler API helpers — extract literal values from a
|
|
4
|
+
* `.ts` source file without running it. Used by every drift-gate or
|
|
5
|
+
* generator that needs to read a structured-but-typed export
|
|
6
|
+
* (component data, metadata, token manifests).
|
|
7
|
+
*
|
|
8
|
+
* The pattern: parse the file with `ts.createProgram`, walk the AST
|
|
9
|
+
* looking for `VariableStatement` nodes whose initializer is an array,
|
|
10
|
+
* object, or literal we can evaluate statically. `evalNode` handles the
|
|
11
|
+
* common literal shapes (strings, numbers, booleans, nested arrays,
|
|
12
|
+
* nested object literals, `as const` casts).
|
|
13
|
+
*
|
|
14
|
+
* This is intentionally a *static* evaluator — it does not execute code,
|
|
15
|
+
* resolve identifiers, or follow imports. Drift-gates want a fast,
|
|
16
|
+
* deterministic read of a configuration-shaped file, not a JS runtime.
|
|
17
|
+
*
|
|
18
|
+
* Extracted from `tools/scripts/gen-llms-txt.mjs` (commit 7a61c26-era)
|
|
19
|
+
* so `check-metadata.js`, `check-css-tokens.js`, and the generator share
|
|
20
|
+
* one implementation. CommonJS so the existing `check-*.js` scripts can
|
|
21
|
+
* `require` it without an ESM wrapper.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const ts = require('typescript');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Evaluate an AST node as a static literal. Returns `null` for nodes the
|
|
28
|
+
* evaluator does not understand (function calls, identifiers, computed
|
|
29
|
+
* property names, etc.) — callers should treat `null` as "not statically
|
|
30
|
+
* evaluable" rather than "absent".
|
|
31
|
+
*/
|
|
32
|
+
function evalNode(node) {
|
|
33
|
+
if (!node) return null;
|
|
34
|
+
if (ts.isStringLiteralLike(node)) return node.text;
|
|
35
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
36
|
+
if (node.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
37
|
+
if (ts.isNumericLiteral(node)) return Number(node.text);
|
|
38
|
+
if (
|
|
39
|
+
ts.isPrefixUnaryExpression(node) &&
|
|
40
|
+
node.operator === ts.SyntaxKind.MinusToken
|
|
41
|
+
) {
|
|
42
|
+
const v = evalNode(node.operand);
|
|
43
|
+
return typeof v === 'number' ? -v : null;
|
|
44
|
+
}
|
|
45
|
+
if (ts.isArrayLiteralExpression(node)) return node.elements.map(evalNode);
|
|
46
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
47
|
+
const obj = {};
|
|
48
|
+
for (const prop of node.properties) {
|
|
49
|
+
if (!ts.isPropertyAssignment(prop)) continue;
|
|
50
|
+
const key = ts.isStringLiteralLike(prop.name)
|
|
51
|
+
? prop.name.text
|
|
52
|
+
: ts.isIdentifier(prop.name)
|
|
53
|
+
? prop.name.text
|
|
54
|
+
: null;
|
|
55
|
+
if (!key) continue;
|
|
56
|
+
obj[key] = evalNode(prop.initializer);
|
|
57
|
+
}
|
|
58
|
+
return obj;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse a `.ts` source file and return the exported `VariableStatement`
|
|
65
|
+
* initializers keyed by identifier. `as const` and `satisfies` casts are
|
|
66
|
+
* unwrapped automatically. Non-evaluable initializers map to `null`.
|
|
67
|
+
*
|
|
68
|
+
* Example:
|
|
69
|
+
* const { metadata, COMPONENT_METADATA_REGISTRY } = parseExportedVars(
|
|
70
|
+
* '/abs/path/to/file.ts'
|
|
71
|
+
* );
|
|
72
|
+
*/
|
|
73
|
+
function parseExportedVars(filePath) {
|
|
74
|
+
const program = ts.createProgram([filePath], {
|
|
75
|
+
target: ts.ScriptTarget.Latest,
|
|
76
|
+
moduleResolution: ts.ModuleResolutionKind.NodeNext,
|
|
77
|
+
noEmit: true,
|
|
78
|
+
});
|
|
79
|
+
const sourceFile = program.getSourceFile(filePath);
|
|
80
|
+
if (!sourceFile) throw new Error(`Could not load ${filePath}`);
|
|
81
|
+
|
|
82
|
+
const out = {};
|
|
83
|
+
ts.forEachChild(sourceFile, (node) => {
|
|
84
|
+
if (!ts.isVariableStatement(node)) return;
|
|
85
|
+
for (const decl of node.declarationList.declarations) {
|
|
86
|
+
if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
|
|
87
|
+
let init = decl.initializer;
|
|
88
|
+
if (ts.isAsExpression(init)) init = init.expression;
|
|
89
|
+
if (ts.isSatisfiesExpression && ts.isSatisfiesExpression(init))
|
|
90
|
+
init = init.expression;
|
|
91
|
+
out[decl.name.text] = evalNode(init);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parse a `.ts` source file and return the names of every exported
|
|
99
|
+
* `interface` declaration that ends in a given suffix (default `Spec`).
|
|
100
|
+
* Used by `check-metadata.js` to discover which spec interfaces exist
|
|
101
|
+
* without hard-coding the list.
|
|
102
|
+
*/
|
|
103
|
+
function findExportedInterfaces(filePath, suffix = 'Spec') {
|
|
104
|
+
const program = ts.createProgram([filePath], {
|
|
105
|
+
target: ts.ScriptTarget.Latest,
|
|
106
|
+
moduleResolution: ts.ModuleResolutionKind.NodeNext,
|
|
107
|
+
noEmit: true,
|
|
108
|
+
});
|
|
109
|
+
const sourceFile = program.getSourceFile(filePath);
|
|
110
|
+
if (!sourceFile) throw new Error(`Could not load ${filePath}`);
|
|
111
|
+
|
|
112
|
+
const names = [];
|
|
113
|
+
ts.forEachChild(sourceFile, (node) => {
|
|
114
|
+
if (!ts.isInterfaceDeclaration(node)) return;
|
|
115
|
+
const hasExport = (node.modifiers || []).some(
|
|
116
|
+
(m) => m.kind === ts.SyntaxKind.ExportKeyword,
|
|
117
|
+
);
|
|
118
|
+
if (!hasExport) return;
|
|
119
|
+
const name = node.name.text;
|
|
120
|
+
if (suffix && !name.endsWith(suffix)) return;
|
|
121
|
+
names.push(name);
|
|
122
|
+
});
|
|
123
|
+
return names;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { evalNode, parseExportedVars, findExportedInterfaces };
|