@astryxdesign/cli 0.1.6-canary.e82deeb → 0.1.6-canary.eb23da7
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/docs/integration-authoring.md +105 -0
- package/docs/internationalization.doc.mjs +243 -0
- package/package.json +13 -9
- package/src/api/integration-block-exports.test.mjs +240 -0
- package/src/api/template-suffix.test.mjs +246 -0
- package/src/api/template.mjs +103 -27
- package/src/api/validate-integration.mjs +0 -8
- package/src/doc.mjs +271 -0
- package/src/doc.test.mjs +383 -0
- package/src/lib/component-discovery.importpath.test.mjs +59 -0
- package/src/lib/component-discovery.mjs +15 -5
- package/src/lib/component-loader.mjs +53 -0
- package/src/lib/config-schema.mjs +0 -30
- package/src/lib/xle/registry.mjs +0 -5
- package/src/types/doc.d.ts +139 -0
- package/templates/blocks/components/VisuallyHidden/VisuallyHiddenLiveRegion.doc.mjs +14 -0
- package/templates/blocks/components/VisuallyHidden/VisuallyHiddenLiveRegion.tsx +41 -0
- package/templates/blocks/components/VisuallyHidden/VisuallyHiddenShowcase.doc.mjs +13 -0
- package/templates/blocks/components/VisuallyHidden/VisuallyHiddenShowcase.tsx +78 -0
- package/templates/blocks/components/VisuallyHidden/VisuallyHiddenStructuralHeading.doc.mjs +14 -0
- package/templates/blocks/components/VisuallyHidden/VisuallyHiddenStructuralHeading.tsx +38 -0
- package/templates/blocks/components/VisuallyHidden/VisuallyHiddenSupplementaryContext.doc.mjs +14 -0
- package/templates/blocks/components/VisuallyHidden/VisuallyHiddenSupplementaryContext.tsx +47 -0
- package/templates/themes/neutral/neutralTheme.ts +63 -32
package/src/doc.mjs
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Public doc-authoring API.
|
|
5
|
+
*
|
|
6
|
+
* A doc describes a documentable unit of the design system so the CLI and docs
|
|
7
|
+
* surfaces can list, search, and render it. There are three kinds, each a tiny
|
|
8
|
+
* runtime identity helper that stamps a `type` discriminant (mirroring how
|
|
9
|
+
* `createPageTemplate`/`createBlockTemplate` stamp `type`):
|
|
10
|
+
*
|
|
11
|
+
* - `createComponentDoc` -> `type: 'component'` — a component (or a family).
|
|
12
|
+
* - `createFunctionDoc` -> `type: 'function'` — any function, including
|
|
13
|
+
* hooks: an "inputs + outputs" surface (`params` + `returns`).
|
|
14
|
+
* - `createDoc` -> `type: 'generic'` — reference/topic docs.
|
|
15
|
+
*
|
|
16
|
+
* Like `createConfig`/`createIntegration`/`createBlockTemplate`, these factories
|
|
17
|
+
* do NOT validate. Validation happens at the LOAD boundary, where doc discovery
|
|
18
|
+
* runs the loaded value through {@link ComponentDocSchema}. That schema handles
|
|
19
|
+
* BOTH the new stamped formats and the legacy loose `export const docs = {...}`
|
|
20
|
+
* shape, so existing docs keep loading unchanged (a later PR migrates them).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {z} from 'zod';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A single documented prop. Mirrors `PropDoc` from
|
|
27
|
+
* `@astryxdesign/core/docs-types`. Only `name`, `type`, and `description` are
|
|
28
|
+
* required; everything else is optional. Extra fields (e.g. `slotElements`)
|
|
29
|
+
* pass through so the schema does not have to track every evolution of the
|
|
30
|
+
* rich playground/element surface.
|
|
31
|
+
*/
|
|
32
|
+
const PropSchema = z
|
|
33
|
+
.object({
|
|
34
|
+
name: z.string().min(1, 'prop name is required'),
|
|
35
|
+
type: z.string().min(1, 'prop type is required'),
|
|
36
|
+
description: z.string(),
|
|
37
|
+
default: z.string().optional(),
|
|
38
|
+
required: z.boolean().optional(),
|
|
39
|
+
})
|
|
40
|
+
.passthrough();
|
|
41
|
+
|
|
42
|
+
/** A single documented function/hook parameter (mirrors `HookParamDoc`). */
|
|
43
|
+
const ParamSchema = z
|
|
44
|
+
.object({
|
|
45
|
+
name: z.string().min(1, 'param name is required'),
|
|
46
|
+
type: z.string().min(1, 'param type is required'),
|
|
47
|
+
description: z.string(),
|
|
48
|
+
default: z.string().optional(),
|
|
49
|
+
required: z.boolean().optional(),
|
|
50
|
+
})
|
|
51
|
+
.passthrough();
|
|
52
|
+
|
|
53
|
+
/** A single documented function/hook return field (mirrors `HookReturnDoc`). */
|
|
54
|
+
const ReturnSchema = z
|
|
55
|
+
.object({
|
|
56
|
+
name: z.string().min(1, 'return name is required'),
|
|
57
|
+
type: z.string().min(1, 'return type is required'),
|
|
58
|
+
description: z.string(),
|
|
59
|
+
})
|
|
60
|
+
.passthrough();
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Fields shared by every doc kind. Deliberately permissive on the rich blobs
|
|
64
|
+
* (usage/theming/playground) — those are large and still evolving, so they are
|
|
65
|
+
* `z.unknown()` passthrough rather than enumerated.
|
|
66
|
+
*
|
|
67
|
+
* `group` vs `parent` are distinct concepts and intentionally separate fields:
|
|
68
|
+
* - `group` is a FLAT sidebar bucket label. Many docs can share a group; it
|
|
69
|
+
* is not an inheritance key.
|
|
70
|
+
* - `parent` is a DIRECTED inheritance/composition pointer — a doc naming the
|
|
71
|
+
* doc it belongs to (e.g. a sub-component naming its parent component). The
|
|
72
|
+
* legacy `subComponentOf` field is a synonym; both map to the same concept.
|
|
73
|
+
*
|
|
74
|
+
* `relatedDocs` is a single flat curated cross-reference list. It replaces the
|
|
75
|
+
* legacy split `relatedComponents` + `relatedHooks`; a downstream reader can
|
|
76
|
+
* merge the legacy pair into it.
|
|
77
|
+
*/
|
|
78
|
+
const BaseDocFields = {
|
|
79
|
+
/** Name of the documented unit, without any prefix. Required. */
|
|
80
|
+
name: z.string().min(1, 'name is required'),
|
|
81
|
+
/** Human-readable display name for gallery/sidebar. */
|
|
82
|
+
displayName: z.string().optional(),
|
|
83
|
+
/** One-line/short description. */
|
|
84
|
+
description: z.string().optional(),
|
|
85
|
+
/** Usage documentation (description, best practices, anatomy, slotElements). */
|
|
86
|
+
usage: z.unknown().optional(),
|
|
87
|
+
/** Flat sidebar grouping label (not an inheritance key). */
|
|
88
|
+
group: z.string().optional(),
|
|
89
|
+
/** Overview-page functional category. */
|
|
90
|
+
category: z.string().optional(),
|
|
91
|
+
/** CLI fuzzy-search keywords. */
|
|
92
|
+
keywords: z.array(z.string()).optional(),
|
|
93
|
+
/** Directed inheritance/composition pointer to the doc this belongs to. */
|
|
94
|
+
parent: z.string().optional(),
|
|
95
|
+
/** Flat curated cross-reference list of related doc names. */
|
|
96
|
+
relatedDocs: z.array(z.string()).optional(),
|
|
97
|
+
/** Hide the whole doc from human-facing UI (stays importable/discoverable). */
|
|
98
|
+
hidden: z.boolean().optional(),
|
|
99
|
+
/** Exclude from the categorized overview page (kept in sidebar/CLI). */
|
|
100
|
+
isHiddenFromOverview: z.boolean().optional(),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* New-format schema for a stamped component doc (`type: 'component'`). The
|
|
105
|
+
* top-level component keys are enumerated, but nested blobs stay loose so real
|
|
106
|
+
* docs are not rejected. `.passthrough()` keeps unknown top-level fields
|
|
107
|
+
* (translations, element descriptors, ...) flowing through.
|
|
108
|
+
*/
|
|
109
|
+
export const ComponentDocKindSchema = z
|
|
110
|
+
.object({
|
|
111
|
+
...BaseDocFields,
|
|
112
|
+
type: z.literal('component'),
|
|
113
|
+
props: z.array(PropSchema),
|
|
114
|
+
theming: z.unknown().optional(),
|
|
115
|
+
playground: z.unknown().optional(),
|
|
116
|
+
examples: z.array(z.unknown()).optional(),
|
|
117
|
+
})
|
|
118
|
+
.passthrough();
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* New-format schema for a stamped function doc (`type: 'function'`). Covers any
|
|
122
|
+
* function, including hooks: an inputs (`params`) + outputs (`returns`) surface.
|
|
123
|
+
*/
|
|
124
|
+
export const FunctionDocKindSchema = z
|
|
125
|
+
.object({
|
|
126
|
+
...BaseDocFields,
|
|
127
|
+
type: z.literal('function'),
|
|
128
|
+
params: z.array(ParamSchema),
|
|
129
|
+
returns: z.array(ReturnSchema),
|
|
130
|
+
})
|
|
131
|
+
.passthrough();
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* New-format schema for a stamped generic doc (`type: 'generic'`) — reference
|
|
135
|
+
* and topic docs. Just the shared base plus the discriminant.
|
|
136
|
+
*/
|
|
137
|
+
export const GenericDocKindSchema = z
|
|
138
|
+
.object({
|
|
139
|
+
...BaseDocFields,
|
|
140
|
+
type: z.literal('generic'),
|
|
141
|
+
})
|
|
142
|
+
.passthrough();
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The stamped-doc contract: one of the three new per-kind schemas, discriminated
|
|
146
|
+
* by the injected `type`. Used when a loaded doc carries a `type` field.
|
|
147
|
+
*/
|
|
148
|
+
export const StampedDocSchema = z.discriminatedUnion('type', [
|
|
149
|
+
ComponentDocKindSchema,
|
|
150
|
+
FunctionDocKindSchema,
|
|
151
|
+
GenericDocKindSchema,
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
// ── Legacy loose format (unchanged, kept for back-compat) ─────────────
|
|
155
|
+
//
|
|
156
|
+
// The pre-factory docs are authored as a loose, untyped object with no `type`
|
|
157
|
+
// discriminant and validated by shape. Enumerated top-level fields observed
|
|
158
|
+
// across the existing loose docs:
|
|
159
|
+
// name, displayName, description, group, category, keywords,
|
|
160
|
+
// isHiddenFromOverview, hidden, hiddenComponents, usage, props, components,
|
|
161
|
+
// subComponentOf, playground, theming, examples, params, returns,
|
|
162
|
+
// relatedComponents, relatedHooks, relatedDocs, parent, importPath.
|
|
163
|
+
|
|
164
|
+
const LegacyBaseDocSchema = z.object({
|
|
165
|
+
name: z.string().min(1, 'name is required'),
|
|
166
|
+
displayName: z.string().optional(),
|
|
167
|
+
description: z.string().optional(),
|
|
168
|
+
group: z.string().optional(),
|
|
169
|
+
category: z.string().optional(),
|
|
170
|
+
keywords: z.array(z.string()).optional(),
|
|
171
|
+
isHiddenFromOverview: z.boolean().optional(),
|
|
172
|
+
hidden: z.boolean().optional(),
|
|
173
|
+
hiddenComponents: z.array(z.string()).optional(),
|
|
174
|
+
usage: z.unknown().optional(),
|
|
175
|
+
playground: z.unknown().optional(),
|
|
176
|
+
theming: z.unknown().optional(),
|
|
177
|
+
examples: z.array(z.unknown()).optional(),
|
|
178
|
+
showcase: z.unknown().optional(),
|
|
179
|
+
// `parent` and legacy `subComponentOf` are synonyms; both accepted here so a
|
|
180
|
+
// parent-based doc that omits a stamped `type` still validates.
|
|
181
|
+
parent: z.string().optional(),
|
|
182
|
+
relatedDocs: z.array(z.string()).optional(),
|
|
183
|
+
relatedComponents: z.array(z.string()).optional(),
|
|
184
|
+
relatedHooks: z.array(z.string()).optional(),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/** Legacy: directory exporting a single primary component (props inline). */
|
|
188
|
+
const LegacySingleComponentDocSchema = LegacyBaseDocSchema.extend({
|
|
189
|
+
props: z.array(PropSchema),
|
|
190
|
+
}).passthrough();
|
|
191
|
+
|
|
192
|
+
/** Legacy: directory exporting multiple components (props on each entry). */
|
|
193
|
+
const LegacyMultiComponentDocSchema = LegacyBaseDocSchema.extend({
|
|
194
|
+
components: z.array(z.unknown()),
|
|
195
|
+
}).passthrough();
|
|
196
|
+
|
|
197
|
+
/** Legacy: a standalone hook doc — inputs (`params`) + outputs (`returns`). */
|
|
198
|
+
const LegacyHookDocSchema = LegacyBaseDocSchema.extend({
|
|
199
|
+
params: z.array(ParamSchema),
|
|
200
|
+
returns: z.array(ReturnSchema),
|
|
201
|
+
}).passthrough();
|
|
202
|
+
|
|
203
|
+
/** Legacy: a sub-component doc parented via `subComponentOf`. */
|
|
204
|
+
const LegacySubComponentDocSchema = LegacyBaseDocSchema.extend({
|
|
205
|
+
subComponentOf: z.string().min(1, 'subComponentOf is required'),
|
|
206
|
+
description: z.string(),
|
|
207
|
+
props: z.array(PropSchema),
|
|
208
|
+
}).passthrough();
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The permissive legacy union. `subComponentOf` is listed first so a doc that
|
|
212
|
+
* carries both `subComponentOf` and `props` is validated as a sub-component
|
|
213
|
+
* (the more specific shape); hook (`params`/`returns`) before multi/single.
|
|
214
|
+
*/
|
|
215
|
+
export const LegacyDocSchema = z.union([
|
|
216
|
+
LegacySubComponentDocSchema,
|
|
217
|
+
LegacyHookDocSchema,
|
|
218
|
+
LegacyMultiComponentDocSchema,
|
|
219
|
+
LegacySingleComponentDocSchema,
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The LOAD-boundary contract for a doc. Handles BOTH formats:
|
|
224
|
+
* - NEW: a stamped doc (`type: 'component' | 'function' | 'generic'`,
|
|
225
|
+
* produced by the factories below) is validated against the matching
|
|
226
|
+
* per-kind schema in {@link StampedDocSchema}.
|
|
227
|
+
* - OLD: a loose, unstamped doc is validated against the permissive
|
|
228
|
+
* {@link LegacyDocSchema} union (the three legacy shapes + standalone hook).
|
|
229
|
+
*
|
|
230
|
+
* Discovery validates the SHAPE, not "was it made by the factory". This PR does
|
|
231
|
+
* NOT migrate real docs; a later PR does. Both formats normalize into the same
|
|
232
|
+
* internal shape consumers already expect.
|
|
233
|
+
*/
|
|
234
|
+
export const ComponentDocSchema = z.union([StampedDocSchema, LegacyDocSchema]);
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Author a component doc. Stamp-only: returns the def with `type: 'component'`
|
|
238
|
+
* injected. Validation happens at the load boundary.
|
|
239
|
+
*
|
|
240
|
+
* @template {import('./types/doc').AstryxComponentDocInput} T
|
|
241
|
+
* @param {T} def
|
|
242
|
+
* @returns {T & {type: 'component'}}
|
|
243
|
+
*/
|
|
244
|
+
export function createComponentDoc(def) {
|
|
245
|
+
return /** @type {T & {type: 'component'}} */ ({...def, type: 'component'});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Author a function doc (covers any function, including hooks). Stamp-only:
|
|
250
|
+
* returns the def with `type: 'function'` injected. Validation happens at the
|
|
251
|
+
* load boundary.
|
|
252
|
+
*
|
|
253
|
+
* @template {import('./types/doc').AstryxFunctionDocInput} T
|
|
254
|
+
* @param {T} def
|
|
255
|
+
* @returns {T & {type: 'function'}}
|
|
256
|
+
*/
|
|
257
|
+
export function createFunctionDoc(def) {
|
|
258
|
+
return /** @type {T & {type: 'function'}} */ ({...def, type: 'function'});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Author a generic reference/topic doc. Stamp-only: returns the def with
|
|
263
|
+
* `type: 'generic'` injected. Validation happens at the load boundary.
|
|
264
|
+
*
|
|
265
|
+
* @template {import('./types/doc').AstryxGenericDocInput} T
|
|
266
|
+
* @param {T} def
|
|
267
|
+
* @returns {T & {type: 'generic'}}
|
|
268
|
+
*/
|
|
269
|
+
export function createDoc(def) {
|
|
270
|
+
return /** @type {T & {type: 'generic'}} */ ({...def, type: 'generic'});
|
|
271
|
+
}
|
package/src/doc.test.mjs
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
createComponentDoc,
|
|
8
|
+
createFunctionDoc,
|
|
9
|
+
createDoc,
|
|
10
|
+
ComponentDocSchema,
|
|
11
|
+
ComponentDocKindSchema,
|
|
12
|
+
FunctionDocKindSchema,
|
|
13
|
+
GenericDocKindSchema,
|
|
14
|
+
} from './doc.mjs';
|
|
15
|
+
import {loadComponentDoc} from './lib/component-loader.mjs';
|
|
16
|
+
|
|
17
|
+
// The factories are stamp-only (like createConfig / createBlockTemplate): they
|
|
18
|
+
// inject a `type` discriminant and are otherwise identity, performing NO
|
|
19
|
+
// runtime validation. Validation happens at the LOAD boundary
|
|
20
|
+
// (loadComponentDoc runs the loaded value through ComponentDocSchema), so the
|
|
21
|
+
// rejection cases assert the schema rejects rather than the factory throwing.
|
|
22
|
+
|
|
23
|
+
const goodComponent = {
|
|
24
|
+
name: 'Widget',
|
|
25
|
+
displayName: 'Widget',
|
|
26
|
+
description: 'A small widget.',
|
|
27
|
+
props: [
|
|
28
|
+
{name: 'label', type: 'string', description: 'Visible label.', required: true},
|
|
29
|
+
{name: 'size', type: "'sm' | 'md'", description: 'Control size.', default: "'md'"},
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const goodFunction = {
|
|
34
|
+
name: 'useThing',
|
|
35
|
+
displayName: 'useThing',
|
|
36
|
+
description: 'A thing hook.',
|
|
37
|
+
params: [{name: 'input', type: 'string', description: 'The input.', required: true}],
|
|
38
|
+
returns: [{name: 'value', type: 'string', description: 'The result.'}],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const goodGeneric = {
|
|
42
|
+
name: 'Theming',
|
|
43
|
+
displayName: 'Theming',
|
|
44
|
+
description: 'How theming works.',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe('doc factories (stamp-only)', () => {
|
|
48
|
+
it('createComponentDoc stamps type: component and is otherwise identity', () => {
|
|
49
|
+
const doc = createComponentDoc(goodComponent);
|
|
50
|
+
expect(doc.type).toBe('component');
|
|
51
|
+
const {type, ...rest} = doc;
|
|
52
|
+
expect(rest).toEqual(goodComponent);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('createFunctionDoc stamps type: function and is otherwise identity', () => {
|
|
56
|
+
const doc = createFunctionDoc(goodFunction);
|
|
57
|
+
expect(doc.type).toBe('function');
|
|
58
|
+
const {type, ...rest} = doc;
|
|
59
|
+
expect(rest).toEqual(goodFunction);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('createDoc stamps type: generic and is otherwise identity', () => {
|
|
63
|
+
const doc = createDoc(goodGeneric);
|
|
64
|
+
expect(doc.type).toBe('generic');
|
|
65
|
+
const {type, ...rest} = doc;
|
|
66
|
+
expect(rest).toEqual(goodGeneric);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('does NOT validate — stamps invalid shapes unchanged', () => {
|
|
70
|
+
const bogus = {group: 'Buttons'}; // no name
|
|
71
|
+
expect(createComponentDoc(bogus)).toEqual({...bogus, type: 'component'});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('per-kind schemas (new stamped format)', () => {
|
|
76
|
+
it('ComponentDocKindSchema accepts a valid component doc', () => {
|
|
77
|
+
expect(() =>
|
|
78
|
+
ComponentDocKindSchema.parse(createComponentDoc(goodComponent)),
|
|
79
|
+
).not.toThrow();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('ComponentDocKindSchema rejects a missing name with a readable message', () => {
|
|
83
|
+
expect(() =>
|
|
84
|
+
ComponentDocKindSchema.parse({type: 'component', name: '', props: []}),
|
|
85
|
+
).toThrow(/name is required/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('ComponentDocKindSchema rejects a prop missing its type with a readable message', () => {
|
|
89
|
+
expect(() =>
|
|
90
|
+
ComponentDocKindSchema.parse({
|
|
91
|
+
type: 'component',
|
|
92
|
+
name: 'Widget',
|
|
93
|
+
props: [{name: 'label', description: 'no type'}],
|
|
94
|
+
}),
|
|
95
|
+
).toThrow(/type/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('ComponentDocKindSchema surfaces the custom message for an empty prop type', () => {
|
|
99
|
+
expect(() =>
|
|
100
|
+
ComponentDocKindSchema.parse({
|
|
101
|
+
type: 'component',
|
|
102
|
+
name: 'Widget',
|
|
103
|
+
props: [{name: 'label', type: '', description: 'empty type'}],
|
|
104
|
+
}),
|
|
105
|
+
).toThrow(/prop type is required/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('FunctionDocKindSchema accepts a valid function doc', () => {
|
|
109
|
+
expect(() =>
|
|
110
|
+
FunctionDocKindSchema.parse(createFunctionDoc(goodFunction)),
|
|
111
|
+
).not.toThrow();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('FunctionDocKindSchema rejects a function doc missing returns', () => {
|
|
115
|
+
expect(() =>
|
|
116
|
+
FunctionDocKindSchema.parse({
|
|
117
|
+
type: 'function',
|
|
118
|
+
name: 'useThing',
|
|
119
|
+
params: [],
|
|
120
|
+
}),
|
|
121
|
+
).toThrow();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('GenericDocKindSchema accepts a valid generic doc', () => {
|
|
125
|
+
expect(() =>
|
|
126
|
+
GenericDocKindSchema.parse(createDoc(goodGeneric)),
|
|
127
|
+
).not.toThrow();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('keeps nested rich blobs loose (usage/theming/playground passthrough)', () => {
|
|
131
|
+
const doc = createComponentDoc({
|
|
132
|
+
...goodComponent,
|
|
133
|
+
usage: {description: 'Use it.', anatomy: [{name: 'root'}]},
|
|
134
|
+
theming: {targets: [{className: 'astryx-widget'}]},
|
|
135
|
+
playground: {defaults: {label: 'Hi'}},
|
|
136
|
+
examples: [{title: 'Basic', code: '<Widget />'}],
|
|
137
|
+
});
|
|
138
|
+
expect(() => ComponentDocKindSchema.parse(doc)).not.toThrow();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('accepts parent + relatedDocs on the shared base', () => {
|
|
142
|
+
const doc = createComponentDoc({
|
|
143
|
+
...goodComponent,
|
|
144
|
+
parent: 'WidgetGroup',
|
|
145
|
+
relatedDocs: ['Gauge', 'useThing'],
|
|
146
|
+
group: 'Widgets',
|
|
147
|
+
});
|
|
148
|
+
const parsed = ComponentDocKindSchema.parse(doc);
|
|
149
|
+
expect(parsed.parent).toBe('WidgetGroup');
|
|
150
|
+
expect(parsed.relatedDocs).toEqual(['Gauge', 'useThing']);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('ComponentDocSchema (load-boundary, both formats)', () => {
|
|
155
|
+
it('accepts a stamped component doc via the per-kind schema', () => {
|
|
156
|
+
expect(() =>
|
|
157
|
+
ComponentDocSchema.parse(createComponentDoc(goodComponent)),
|
|
158
|
+
).not.toThrow();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('accepts a stamped function doc', () => {
|
|
162
|
+
expect(() =>
|
|
163
|
+
ComponentDocSchema.parse(createFunctionDoc(goodFunction)),
|
|
164
|
+
).not.toThrow();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('accepts a stamped generic doc', () => {
|
|
168
|
+
expect(() =>
|
|
169
|
+
ComponentDocSchema.parse(createDoc(goodGeneric)),
|
|
170
|
+
).not.toThrow();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('accepts the OLD loose single-component shape (no type)', () => {
|
|
174
|
+
expect(() => ComponentDocSchema.parse(goodComponent)).not.toThrow();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('accepts the OLD loose multi-component shape (components[])', () => {
|
|
178
|
+
const multi = {
|
|
179
|
+
name: 'Table',
|
|
180
|
+
displayName: 'Table',
|
|
181
|
+
components: [{name: 'TableRow', displayName: 'Table Row', description: 'A row.'}],
|
|
182
|
+
};
|
|
183
|
+
expect(() => ComponentDocSchema.parse(multi)).not.toThrow();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('accepts the OLD loose sub-component shape (subComponentOf)', () => {
|
|
187
|
+
const sub = {
|
|
188
|
+
name: 'GaugeItem',
|
|
189
|
+
subComponentOf: 'Gauge',
|
|
190
|
+
displayName: 'Gauge Item',
|
|
191
|
+
description: 'A gauge item.',
|
|
192
|
+
props: [{name: 'item', type: 'Item', description: 'The item.'}],
|
|
193
|
+
};
|
|
194
|
+
expect(() => ComponentDocSchema.parse(sub)).not.toThrow();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('accepts the OLD loose standalone-hook shape (params + returns, no type)', () => {
|
|
198
|
+
const hook = {
|
|
199
|
+
name: 'useThing',
|
|
200
|
+
displayName: 'useThing',
|
|
201
|
+
params: [{name: 'q', type: 'string', description: 'query'}],
|
|
202
|
+
returns: [{name: 'value', type: 'boolean', description: 'match'}],
|
|
203
|
+
};
|
|
204
|
+
expect(() => ComponentDocSchema.parse(hook)).not.toThrow();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('accepts BOTH parent and legacy subComponentOf', () => {
|
|
208
|
+
const withParent = {name: 'A', parent: 'B', props: []};
|
|
209
|
+
const withSubComponentOf = {
|
|
210
|
+
name: 'A',
|
|
211
|
+
subComponentOf: 'B',
|
|
212
|
+
description: 'sub',
|
|
213
|
+
props: [],
|
|
214
|
+
};
|
|
215
|
+
expect(() => ComponentDocSchema.parse(withParent)).not.toThrow();
|
|
216
|
+
expect(() => ComponentDocSchema.parse(withSubComponentOf)).not.toThrow();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('accepts BOTH relatedDocs and legacy relatedComponents/relatedHooks', () => {
|
|
220
|
+
const legacy = {
|
|
221
|
+
name: 'useThing',
|
|
222
|
+
displayName: 'useThing',
|
|
223
|
+
params: [],
|
|
224
|
+
returns: [],
|
|
225
|
+
relatedComponents: ['Gauge'],
|
|
226
|
+
relatedHooks: ['useOther'],
|
|
227
|
+
};
|
|
228
|
+
const modern = {...goodComponent, relatedDocs: ['Gauge', 'useThing']};
|
|
229
|
+
expect(() => ComponentDocSchema.parse(legacy)).not.toThrow();
|
|
230
|
+
expect(() =>
|
|
231
|
+
ComponentDocSchema.parse(createComponentDoc(modern)),
|
|
232
|
+
).not.toThrow();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('passes through loose extras (usage, playground, theming, importPath, showcase)', () => {
|
|
236
|
+
const loose = {
|
|
237
|
+
...goodComponent,
|
|
238
|
+
usage: {description: 'Use it wisely.'},
|
|
239
|
+
playground: {defaults: {label: 'Hi'}},
|
|
240
|
+
theming: {targets: [{className: 'astryx-widget'}]},
|
|
241
|
+
keywords: ['widget', 'thing'],
|
|
242
|
+
category: 'Content',
|
|
243
|
+
isHiddenFromOverview: true,
|
|
244
|
+
importPath: '@astryxdesign/core/Widget',
|
|
245
|
+
showcase: 'WidgetHero',
|
|
246
|
+
};
|
|
247
|
+
const parsed = ComponentDocSchema.parse(loose);
|
|
248
|
+
expect(parsed.importPath).toBe('@astryxdesign/core/Widget');
|
|
249
|
+
expect(parsed.showcase).toBe('WidgetHero');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('rejects a doc with no name', () => {
|
|
253
|
+
expect(() => ComponentDocSchema.parse({props: []})).toThrow();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('rejects an empty name with a readable message', () => {
|
|
257
|
+
expect(() => ComponentDocSchema.parse({name: '', props: []})).toThrow(
|
|
258
|
+
/name is required/,
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('loadComponentDoc (load boundary)', () => {
|
|
264
|
+
let tmpDir;
|
|
265
|
+
|
|
266
|
+
beforeEach(() => {
|
|
267
|
+
tmpDir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-doc-test-'));
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
afterEach(() => {
|
|
271
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const docModule = () => JSON.stringify(path.resolve(import.meta.dirname, 'doc.mjs'));
|
|
275
|
+
|
|
276
|
+
it('loads a .doc.ts fixture via jiti (createComponentDoc default export)', async () => {
|
|
277
|
+
const file = path.join(tmpDir, 'Widget.doc.ts');
|
|
278
|
+
fs.writeFileSync(
|
|
279
|
+
file,
|
|
280
|
+
[
|
|
281
|
+
`import {createComponentDoc} from ${docModule()};`,
|
|
282
|
+
'export default createComponentDoc({',
|
|
283
|
+
" name: 'Widget',",
|
|
284
|
+
" displayName: 'Widget',",
|
|
285
|
+
" description: 'A small widget.',",
|
|
286
|
+
' props: [',
|
|
287
|
+
" {name: 'label', type: 'string', description: 'Visible label.', required: true},",
|
|
288
|
+
' ],',
|
|
289
|
+
'});',
|
|
290
|
+
].join('\n'),
|
|
291
|
+
);
|
|
292
|
+
const docs = await loadComponentDoc(file);
|
|
293
|
+
expect(docs.type).toBe('component');
|
|
294
|
+
expect(docs.name).toBe('Widget');
|
|
295
|
+
expect(docs.props).toHaveLength(1);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('loads a .doc.ts fixture via jiti (createFunctionDoc default export)', async () => {
|
|
299
|
+
const file = path.join(tmpDir, 'useThing.doc.ts');
|
|
300
|
+
fs.writeFileSync(
|
|
301
|
+
file,
|
|
302
|
+
[
|
|
303
|
+
`import {createFunctionDoc} from ${docModule()};`,
|
|
304
|
+
'export default createFunctionDoc({',
|
|
305
|
+
" name: 'useThing',",
|
|
306
|
+
" displayName: 'useThing',",
|
|
307
|
+
" params: [{name: 'input', type: 'string', description: 'The input.'}],",
|
|
308
|
+
" returns: [{name: 'value', type: 'string', description: 'The result.'}],",
|
|
309
|
+
'});',
|
|
310
|
+
].join('\n'),
|
|
311
|
+
);
|
|
312
|
+
const docs = await loadComponentDoc(file);
|
|
313
|
+
expect(docs.type).toBe('function');
|
|
314
|
+
expect(docs.params).toHaveLength(1);
|
|
315
|
+
expect(docs.returns).toHaveLength(1);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('loads a .doc.ts fixture via jiti (createDoc generic default export)', async () => {
|
|
319
|
+
const file = path.join(tmpDir, 'Theming.doc.ts');
|
|
320
|
+
fs.writeFileSync(
|
|
321
|
+
file,
|
|
322
|
+
[
|
|
323
|
+
`import {createDoc} from ${docModule()};`,
|
|
324
|
+
'export default createDoc({',
|
|
325
|
+
" name: 'Theming',",
|
|
326
|
+
" displayName: 'Theming',",
|
|
327
|
+
" description: 'How theming works.',",
|
|
328
|
+
'});',
|
|
329
|
+
].join('\n'),
|
|
330
|
+
);
|
|
331
|
+
const docs = await loadComponentDoc(file);
|
|
332
|
+
expect(docs.type).toBe('generic');
|
|
333
|
+
expect(docs.name).toBe('Theming');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('REGRESSION: loads the OLD loose `export const docs = {}` format', async () => {
|
|
337
|
+
const file = path.join(tmpDir, 'Legacy.doc.mjs');
|
|
338
|
+
fs.writeFileSync(
|
|
339
|
+
file,
|
|
340
|
+
[
|
|
341
|
+
'export const docs = {',
|
|
342
|
+
" name: 'Legacy',",
|
|
343
|
+
" displayName: 'Legacy',",
|
|
344
|
+
" description: 'A loose named-export doc.',",
|
|
345
|
+
" relatedComponents: ['Gauge'],",
|
|
346
|
+
" relatedHooks: ['useThing'],",
|
|
347
|
+
" importPath: '@astryxdesign/core/Legacy',",
|
|
348
|
+
" props: [{name: 'value', type: 'string', description: 'A value.'}],",
|
|
349
|
+
'};',
|
|
350
|
+
].join('\n'),
|
|
351
|
+
);
|
|
352
|
+
const docs = await loadComponentDoc(file);
|
|
353
|
+
expect(docs.name).toBe('Legacy');
|
|
354
|
+
expect(docs.props[0].name).toBe('value');
|
|
355
|
+
expect(docs.relatedComponents).toEqual(['Gauge']);
|
|
356
|
+
expect(docs.importPath).toBe('@astryxdesign/core/Legacy');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('REGRESSION: loads the OLD loose standalone-hook `docs` format', async () => {
|
|
360
|
+
const file = path.join(tmpDir, 'useLegacy.doc.mjs');
|
|
361
|
+
fs.writeFileSync(
|
|
362
|
+
file,
|
|
363
|
+
[
|
|
364
|
+
'export const docs = {',
|
|
365
|
+
" name: 'useLegacy',",
|
|
366
|
+
" displayName: 'useLegacy',",
|
|
367
|
+
" params: [{name: 'q', type: 'string', description: 'query'}],",
|
|
368
|
+
" returns: [{name: 'value', type: 'boolean', description: 'match'}],",
|
|
369
|
+
" relatedHooks: ['useThing'],",
|
|
370
|
+
'};',
|
|
371
|
+
].join('\n'),
|
|
372
|
+
);
|
|
373
|
+
const docs = await loadComponentDoc(file);
|
|
374
|
+
expect(docs.name).toBe('useLegacy');
|
|
375
|
+
expect(docs.returns[0].name).toBe('value');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('throws a readable error for an invalid doc', async () => {
|
|
379
|
+
const file = path.join(tmpDir, 'Bad.doc.mjs');
|
|
380
|
+
fs.writeFileSync(file, 'export default {group: "Buttons"};\n');
|
|
381
|
+
await expect(loadComponentDoc(file)).rejects.toThrow(/is invalid|name/);
|
|
382
|
+
});
|
|
383
|
+
});
|