@astryxdesign/cli 0.1.1 → 0.1.2-canary.2149fa3
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 +50 -0
- package/docs/theme.doc.mjs +2 -2
- package/docs/working-with-ai.doc.mjs +6 -6
- package/package.json +19 -8
- package/src/api/discover.mjs +78 -26
- package/src/api/index.mjs +1 -0
- package/src/api/layout.mjs +301 -0
- package/src/api/layout.test.mjs +238 -0
- package/src/api/template.mjs +191 -50
- package/src/api/template.test.mjs +2 -0
- package/src/api/theme-add.mjs +182 -0
- package/src/codemods/__tests__/registry.test.mjs +1 -0
- package/src/codemods/registry.mjs +1 -0
- package/src/codemods/transforms/v0.1.2/__tests__/rename-text-color-active-to-accent.test.mjs +120 -0
- package/src/codemods/transforms/v0.1.2/index.mjs +19 -0
- package/src/codemods/transforms/v0.1.2/rename-text-color-active-to-accent.mjs +136 -0
- package/src/commands/build-theme.import-path.test.mjs +22 -2
- package/src/commands/build-theme.mjs +185 -16
- package/src/commands/gap-report.mjs +17 -9
- package/src/commands/gap-report.test.mjs +21 -16
- package/src/commands/init.mjs +34 -8
- package/src/commands/init.next-steps.test.mjs +46 -0
- package/src/commands/layout.mjs +139 -0
- package/src/commands/swizzle.mjs +51 -23
- package/src/commands/upgrade.mjs +2 -71
- package/src/config.mjs +31 -0
- package/src/config.test.mjs +24 -0
- package/src/index.mjs +6 -0
- package/src/lib/config-schema.mjs +119 -0
- package/src/lib/config.mjs +34 -7
- package/src/lib/config.test.mjs +51 -2
- package/src/lib/error-codes.mjs +11 -0
- package/src/lib/integrations.mjs +155 -0
- package/src/lib/integrations.test.mjs +154 -0
- package/src/lib/levenshtein.mjs +29 -0
- package/src/lib/manifest.mjs +10 -0
- package/src/lib/package-scanner.mjs +31 -7
- package/src/lib/string-utils.mjs +5 -14
- package/src/lib/xle/browser.d.ts +100 -0
- package/src/lib/xle/browser.mjs +120 -0
- package/src/lib/xle/expand.mjs +622 -0
- package/src/lib/xle/parse.mjs +581 -0
- package/src/lib/xle/print.mjs +174 -0
- package/src/lib/xle/registry-core.mjs +170 -0
- package/src/lib/xle/registry.mjs +237 -0
- package/src/lib/xle/splice.mjs +137 -0
- package/src/lib/xle/validate.mjs +356 -0
- package/src/lib/xle/xle.test.mjs +333 -0
- package/src/types/config.d.ts +99 -0
- package/src/utils/github.mjs +12 -27
- package/templates/blocks/components/Card/ClickableCardWithNestedButton.doc.mjs +1 -1
- package/templates/blocks/components/Card/SelectableCardMulti.doc.mjs +1 -1
- package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerWithProgress.tsx +1 -1
- package/templates/blocks/components/ChatDictationButton/ChatDictationButtonShowcase.tsx +3 -3
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
- package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
- package/templates/blocks/components/FileInput/FileInputShowcase.tsx +4 -2
- package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +14 -0
- package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.tsx +43 -0
- package/templates/blocks/components/MultiSelector/MultiSelectorShowcase.tsx +6 -2
- package/templates/blocks/components/NumberInput/NumberInputShowcase.tsx +6 -2
- package/templates/blocks/components/Outline/OutlineShowcase.doc.mjs +15 -0
- package/templates/blocks/components/Outline/OutlineShowcase.tsx +22 -0
- package/templates/blocks/components/RadioList/RadioListShowcase.tsx +8 -1
- package/templates/blocks/components/SegmentedControl/SegmentedControlShowcase.tsx +3 -1
- package/templates/blocks/components/Selector/SelectorShowcase.tsx +4 -1
- package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
- package/templates/blocks/components/Tab/TabShowcase.tsx +3 -1
- package/templates/blocks/components/TabList/TabListShowcase.tsx +3 -1
- package/templates/blocks/components/TabMenu/TabMenuShowcase.tsx +3 -1
- package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
- package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
- package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
- package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
- package/templates/blocks/components/Text/TextColors.tsx +20 -5
- package/templates/blocks/components/TextArea/TextAreaShowcase.tsx +4 -2
- package/templates/blocks/components/TextInput/TextInputShowcase.tsx +4 -2
- package/templates/blocks/components/Thumbnail/ThumbnailDisabled.tsx +11 -6
- package/templates/blocks/components/Thumbnail/ThumbnailGallery.tsx +43 -7
- package/templates/blocks/components/Thumbnail/ThumbnailRemovable.tsx +40 -3
- package/templates/blocks/components/Thumbnail/ThumbnailShowcase.tsx +6 -5
- package/templates/blocks/components/Thumbnail/ThumbnailStates.tsx +11 -6
- package/templates/blocks/components/Timestamp/TimestampColors.tsx +2 -2
- package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
- package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +8 -6
- package/templates/blocks/components/Toolbar/ToolbarTableFilter.doc.mjs +2 -2
- package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +23 -14
- package/templates/blocks/components/Typeahead/TypeaheadShowcase.tsx +6 -2
- package/templates/pages/shell-nav/page.tsx +321 -0
- package/templates/pages/shell-nav/template.doc.mjs +12 -0
- package/templates/pages/shell-side-nav/page.tsx +242 -0
- package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
- package/templates/pages/shell-top-nav/page.tsx +224 -0
- package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
- package/templates/pages/theme-showcase/page.tsx +8 -19
- package/templates/themes/butter/butterTheme.ts +916 -0
- package/templates/themes/butter/icons.tsx +77 -0
- package/templates/themes/chocolate/chocolateTheme.ts +230 -0
- package/templates/themes/chocolate/icons.tsx +77 -0
- package/templates/themes/gothic/gothicTheme.ts +657 -0
- package/templates/themes/gothic/icons.tsx +77 -0
- package/templates/themes/manifest.json +90 -0
- package/templates/themes/matcha/icons.tsx +67 -0
- package/templates/themes/matcha/matchaTheme.ts +247 -0
- package/templates/themes/neutral/icons.tsx +77 -0
- package/templates/themes/neutral/neutralTheme.ts +603 -0
- package/templates/themes/stone/icons.tsx +77 -0
- package/templates/themes/stone/stoneTheme.ts +652 -0
- package/templates/themes/y2k/icons.tsx +67 -0
- package/templates/themes/y2k/y2kTheme.ts +617 -0
- package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
- package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -57
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Block splicing — turn a template block's TSX source into something
|
|
5
|
+
* the expander can inline.
|
|
6
|
+
*
|
|
7
|
+
* A `{hint}` can resolve two ways:
|
|
8
|
+
* - splice: a template block (not an importable package) is co-defined as a
|
|
9
|
+
* local component in the generated module and referenced — the block's
|
|
10
|
+
* imports are hoisted and merged. This is what `astryx template <name>` would
|
|
11
|
+
* scaffold, inlined.
|
|
12
|
+
* - import: an app-registered local component (KpiCard, charts, …) is simply
|
|
13
|
+
* imported from its configured path and referenced. This is the bridge that
|
|
14
|
+
* lets XLE reach the domain components it's otherwise blind to.
|
|
15
|
+
*
|
|
16
|
+
* Pure (no fs): the Node caller reads files; this only transforms strings, so
|
|
17
|
+
* it stays in the browser barrel.
|
|
18
|
+
*
|
|
19
|
+
* @input block TSX source (splice) or a name+importPath (import)
|
|
20
|
+
* @output prepareSpliceModule / parseImportStatements + import-merge helpers
|
|
21
|
+
* @position lib/xle — used by expand.mjs; sources gathered in api/layout.mjs
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const IMPORT_RE =
|
|
25
|
+
/^import\s+(?:(type)\s+)?(?:([A-Za-z_$][\w$]*)\s*,\s*)?(?:\{([^}]*)\}|\*\s+as\s+([A-Za-z_$][\w$]*)|([A-Za-z_$][\w$]*))?\s*(?:from\s*)?['"]([^'"]+)['"]\s*;?/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse the import statements at the top of a module. Handles named, type,
|
|
29
|
+
* default, namespace, and side-effect imports (incl. simple multiline named
|
|
30
|
+
* lists). Returns {imports, rest} where rest is the source with those import
|
|
31
|
+
* lines removed.
|
|
32
|
+
*/
|
|
33
|
+
export function parseImportStatements(source) {
|
|
34
|
+
const lines = source.split('\n');
|
|
35
|
+
const imports = [];
|
|
36
|
+
const kept = [];
|
|
37
|
+
for (let i = 0; i < lines.length; i++) {
|
|
38
|
+
const line = lines[i];
|
|
39
|
+
if (/^\s*import\b/.test(line)) {
|
|
40
|
+
// Gather a possibly-multiline import up to its terminating quote+;
|
|
41
|
+
let stmt = line;
|
|
42
|
+
while (!/['"]\s*;?\s*$/.test(stmt) && i + 1 < lines.length) {
|
|
43
|
+
i++;
|
|
44
|
+
stmt += '\n' + lines[i];
|
|
45
|
+
}
|
|
46
|
+
const m = stmt.replace(/\s+/g, ' ').match(IMPORT_RE);
|
|
47
|
+
if (m) {
|
|
48
|
+
const [, typeKw, defaultWithNamed, named, namespace, bareDefault, src] = m;
|
|
49
|
+
imports.push({
|
|
50
|
+
source: src,
|
|
51
|
+
isType: Boolean(typeKw),
|
|
52
|
+
default: defaultWithNamed || bareDefault || null,
|
|
53
|
+
namespace: namespace || null,
|
|
54
|
+
named: named
|
|
55
|
+
? named.split(',').map(s => s.trim()).filter(Boolean)
|
|
56
|
+
: [],
|
|
57
|
+
sideEffect: !named && !namespace && !bareDefault && !defaultWithNamed,
|
|
58
|
+
});
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
kept.push(line);
|
|
63
|
+
}
|
|
64
|
+
return {imports, rest: kept.join('\n')};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Prepare a template block for inline co-definition.
|
|
69
|
+
*
|
|
70
|
+
* Strips the copyright header and `'use client'`, hoists imports, and rewrites
|
|
71
|
+
* the block's exports into plain module-level declarations so it can live
|
|
72
|
+
* inside the generated file. Returns null if the block has no default-exported
|
|
73
|
+
* component to reference (caller falls back to a TODO marker).
|
|
74
|
+
*
|
|
75
|
+
* @param {string} source - raw block TSX
|
|
76
|
+
* @param {string} fallbackName - PascalCase name to use if no export name found
|
|
77
|
+
* @returns {{componentName: string, imports: object[], body: string} | null}
|
|
78
|
+
*/
|
|
79
|
+
export function prepareSpliceModule(source, fallbackName) {
|
|
80
|
+
const {imports, rest} = parseImportStatements(source);
|
|
81
|
+
|
|
82
|
+
// Determine the component name from the default export.
|
|
83
|
+
let componentName = fallbackName;
|
|
84
|
+
const defFn = rest.match(/export\s+default\s+function\s+([A-Za-z_$][\w$]*)/);
|
|
85
|
+
const defConst = rest.match(/export\s+default\s+([A-Za-z_$][\w$]*)\s*;/);
|
|
86
|
+
if (defFn) componentName = defFn[1];
|
|
87
|
+
else if (defConst) componentName = defConst[1];
|
|
88
|
+
else if (!/export\s+default/.test(rest)) return null;
|
|
89
|
+
|
|
90
|
+
let body = rest
|
|
91
|
+
// drop directive prologues and the copyright comment
|
|
92
|
+
.replace(/^\s*['"]use client['"];?\s*$/m, '')
|
|
93
|
+
.replace(/^\/\/.*$/gm, '')
|
|
94
|
+
// anonymous default → a named function/const we can reference
|
|
95
|
+
.replace(/export\s+default\s+function\s*\(/, `function ${componentName}(`)
|
|
96
|
+
.replace(/export\s+default\s+function\s+/, 'function ')
|
|
97
|
+
// `export default X;` (X already defined above) → nothing
|
|
98
|
+
.replace(/export\s+default\s+[A-Za-z_$][\w$]*\s*;/, '')
|
|
99
|
+
// demote remaining named exports to plain declarations
|
|
100
|
+
.replace(/export\s+(const|function|class|interface|type|enum)\s/g, '$1 ')
|
|
101
|
+
.trim();
|
|
102
|
+
|
|
103
|
+
return {componentName, imports, body};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Merge a parsed import list into an emitter import map.
|
|
108
|
+
* The map is Map<source, {named:Set, types:Set, default?:string, namespace?:string, sideEffect?:bool}>.
|
|
109
|
+
*/
|
|
110
|
+
export function mergeImports(map, imports) {
|
|
111
|
+
for (const imp of imports) {
|
|
112
|
+
if (!map.has(imp.source)) {
|
|
113
|
+
map.set(imp.source, {named: new Set(), types: new Set(), default: null, namespace: null, sideEffect: false});
|
|
114
|
+
}
|
|
115
|
+
const entry = map.get(imp.source);
|
|
116
|
+
if (imp.sideEffect) entry.sideEffect = true;
|
|
117
|
+
if (imp.default) entry.default = imp.default;
|
|
118
|
+
if (imp.namespace) entry.namespace = imp.namespace;
|
|
119
|
+
for (const n of imp.named) (imp.isType ? entry.types : entry.named).add(n);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Render one merged import entry to a statement string. */
|
|
124
|
+
export function renderImport(source, entry) {
|
|
125
|
+
if (entry.sideEffect && !entry.default && !entry.namespace && entry.named.size === 0 && entry.types.size === 0) {
|
|
126
|
+
return `import '${source}';`;
|
|
127
|
+
}
|
|
128
|
+
const clauses = [];
|
|
129
|
+
if (entry.default) clauses.push(entry.default);
|
|
130
|
+
if (entry.namespace) clauses.push(`* as ${entry.namespace}`);
|
|
131
|
+
const named = [...entry.named].sort();
|
|
132
|
+
const types = [...entry.types].sort();
|
|
133
|
+
// Keep value and type names in one braces group (TS allows inline `type`).
|
|
134
|
+
const braced = [...named, ...types.map(t => `type ${t}`)];
|
|
135
|
+
if (braced.length > 0) clauses.push(`{${braced.join(', ')}}`);
|
|
136
|
+
return `import ${clauses.join(', ')} from '${source}';`;
|
|
137
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file XLE binder/validator — resolves the parsed AST against the
|
|
5
|
+
* component registry and annotates every node with its bound component
|
|
6
|
+
* and prop assignments.
|
|
7
|
+
*
|
|
8
|
+
* Philosophy (from the XLE research): lenient lexer, strict validator.
|
|
9
|
+
* Spelling variants normalize silently (p6 ≡ pad=6 ≡ padding=6), but
|
|
10
|
+
* semantic problems — unknown components, props, enum values, blocks —
|
|
11
|
+
* are hard errors carrying ranked suggestions, never guesses.
|
|
12
|
+
*
|
|
13
|
+
* @input parse() AST + registry (+ block list for {hint} resolution)
|
|
14
|
+
* @output validate() → {errors, warnings}; nodes gain .bound
|
|
15
|
+
* @position lib/xle — between parse.mjs and expand.mjs
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {levenshteinDistance} from '../levenshtein.mjs';
|
|
19
|
+
import {KEY_ALIASES} from './parse.mjs';
|
|
20
|
+
import {resolveComponent} from './registry-core.mjs';
|
|
21
|
+
|
|
22
|
+
/** Boolean flag synonyms — agent-frequency spellings of isX/hasX props. */
|
|
23
|
+
const FLAG_SYNONYMS = {
|
|
24
|
+
req: 'isRequired', opt: 'isOptional', dis: 'isDisabled',
|
|
25
|
+
scroll: 'isScrollable', divider: 'hasDivider', dividers: 'hasDivider',
|
|
26
|
+
striped: 'isStriped', hover: 'hasHover', clear: 'hasClear',
|
|
27
|
+
compact: 'isCompact', external: 'isExternalLink', loading: 'isLoading',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Props the expander treats as the node's primary text payload, in order. */
|
|
31
|
+
export const PAYLOAD_PROPS = ['label', 'title', 'heading'];
|
|
32
|
+
|
|
33
|
+
function suggest(input, candidates, max = 4) {
|
|
34
|
+
const lower = input.toLowerCase();
|
|
35
|
+
return candidates
|
|
36
|
+
.map(name => {
|
|
37
|
+
const candidate = name.toLowerCase();
|
|
38
|
+
const contains = candidate.includes(lower) || lower.includes(candidate);
|
|
39
|
+
const distance = levenshteinDistance(lower, candidate);
|
|
40
|
+
return {name, distance: contains ? Math.min(distance, 1) : distance};
|
|
41
|
+
})
|
|
42
|
+
.filter(s => s.distance <= Math.max(2, Math.floor(input.length / 2)))
|
|
43
|
+
.sort((a, b) => a.distance - b.distance)
|
|
44
|
+
.slice(0, max)
|
|
45
|
+
.map(s => s.name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeBlockKey(name) {
|
|
49
|
+
return name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve a word to a prop assignment via, in order: synonym table,
|
|
54
|
+
* exact boolean prop, is/has-prefixed boolean prop, unique enum value.
|
|
55
|
+
* Returns {prop, value} or null.
|
|
56
|
+
*/
|
|
57
|
+
function resolveWord(component, word) {
|
|
58
|
+
const synonym = FLAG_SYNONYMS[word];
|
|
59
|
+
if (synonym && component.props.has(synonym)) {
|
|
60
|
+
return {prop: synonym, value: true};
|
|
61
|
+
}
|
|
62
|
+
const exact = component.props.get(word);
|
|
63
|
+
if (exact && exact.isBoolean) return {prop: word, value: true};
|
|
64
|
+
const cap = word[0].toUpperCase() + word.slice(1);
|
|
65
|
+
for (const prefix of ['is', 'has']) {
|
|
66
|
+
const pref = component.props.get(prefix + cap);
|
|
67
|
+
if (pref && pref.isBoolean) return {prop: prefix + cap, value: true};
|
|
68
|
+
}
|
|
69
|
+
// Unique enum membership across all enum-typed props.
|
|
70
|
+
const hits = [];
|
|
71
|
+
for (const prop of component.props.values()) {
|
|
72
|
+
if (prop.enumValues && prop.enumValues.includes(word)) hits.push(prop.name);
|
|
73
|
+
}
|
|
74
|
+
if (hits.length === 1) return {prop: hits[0], value: word};
|
|
75
|
+
if (hits.length > 1) return {ambiguous: hits};
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Map axis-neutral j=/a= keys onto the component's real alignment props. */
|
|
80
|
+
function resolveAxisKey(component, key) {
|
|
81
|
+
const main = key === '__mainAxis';
|
|
82
|
+
if (component.name === 'VStack') return main ? 'vAlign' : 'hAlign';
|
|
83
|
+
if (component.name === 'HStack') return main ? 'hAlign' : 'vAlign';
|
|
84
|
+
const generic = main ? 'justify' : 'align';
|
|
85
|
+
if (component.props.has(generic)) return generic;
|
|
86
|
+
if (component.props.has(main ? 'hAlign' : 'vAlign')) return main ? 'hAlign' : 'vAlign';
|
|
87
|
+
return generic;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Grid's `columns` object shorthand: {min,max,fit|fill} → real keys. */
|
|
91
|
+
function normalizeColumnsObject(value) {
|
|
92
|
+
if (typeof value !== 'object' || value == null || Array.isArray(value)) return value;
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const [k, v] of Object.entries(value)) {
|
|
95
|
+
if (k === 'min') out.minWidth = v;
|
|
96
|
+
else if (k === 'fit' || k === 'fill') out.repeat = k;
|
|
97
|
+
else out[k] = v;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class Validation {
|
|
103
|
+
constructor(registry, blocks, {loose = false} = {}) {
|
|
104
|
+
this.registry = registry;
|
|
105
|
+
this.blocks = blocks || [];
|
|
106
|
+
this.blockIndex = new Map(this.blocks.map(b => [normalizeBlockKey(b.dirName), b]));
|
|
107
|
+
this.loose = loose;
|
|
108
|
+
this.errors = [];
|
|
109
|
+
this.warnings = [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
error(node, message, suggestions = []) {
|
|
113
|
+
this.errors.push({message, line: node.line, col: node.col, suggestions});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
warn(node, message) {
|
|
117
|
+
this.warnings.push({message, line: node.line, col: node.col});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
nameCandidates() {
|
|
121
|
+
return [...this.registry.aliases.keys(), ...this.registry.componentNames];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
bindNode(node, parent) {
|
|
125
|
+
// Anonymous block reference ({block} with no host element).
|
|
126
|
+
if (!node.name && node.hint) {
|
|
127
|
+
node.bound = null;
|
|
128
|
+
this.bindHint(node);
|
|
129
|
+
for (const child of node.children) this.bindAny(child, node);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const component = resolveComponent(this.registry, node.name);
|
|
133
|
+
if (!component) {
|
|
134
|
+
this.error(
|
|
135
|
+
node,
|
|
136
|
+
`Unknown component or alias '${node.name}'`,
|
|
137
|
+
suggest(node.name, this.nameCandidates()),
|
|
138
|
+
);
|
|
139
|
+
node.bound = null;
|
|
140
|
+
} else {
|
|
141
|
+
node.bound = {component, props: new Map(), slots: [], stray: []};
|
|
142
|
+
this.bindAttrs(node, component);
|
|
143
|
+
this.bindMods(node, component);
|
|
144
|
+
this.bindSlots(node, component);
|
|
145
|
+
this.checkPairings(node, component, parent);
|
|
146
|
+
}
|
|
147
|
+
if (node.hint) this.bindHint(node);
|
|
148
|
+
for (const child of node.children) this.bindAny(child, node);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
bindAny(item, parent) {
|
|
152
|
+
if (item.kind === 'group') {
|
|
153
|
+
for (const child of item.children) this.bindAny(child, parent);
|
|
154
|
+
} else {
|
|
155
|
+
this.bindNode(item, parent);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
bindAttrs(node, component) {
|
|
160
|
+
if (component.undocumented) {
|
|
161
|
+
// No prop metadata for this export — bind raw and tell the author
|
|
162
|
+
// nothing was checked, rather than rejecting valid props.
|
|
163
|
+
for (const attr of node.attrs) {
|
|
164
|
+
if (attr.kind === 'kv') {
|
|
165
|
+
const key = KEY_ALIASES[attr.key.split('.')[0]] ?? attr.key;
|
|
166
|
+
node.bound.props.set(key.startsWith('__') ? attr.key : key, attr.value);
|
|
167
|
+
} else if (attr.kind === 'flag') {
|
|
168
|
+
node.bound.props.set(attr.key, true);
|
|
169
|
+
} else if (attr.kind === 'neg') {
|
|
170
|
+
node.bound.props.set(attr.key, false);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (node.attrs.length > 0) {
|
|
174
|
+
this.warn(node, `${component.name} has no documented props — attrs passed through unvalidated`);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
for (const attr of node.attrs) {
|
|
179
|
+
if (attr.kind === 'kv') {
|
|
180
|
+
this.bindKv(node, component, attr);
|
|
181
|
+
} else if (attr.kind === 'flag') {
|
|
182
|
+
const hit = resolveWord(component, attr.key);
|
|
183
|
+
if (!hit) {
|
|
184
|
+
this.error(
|
|
185
|
+
node,
|
|
186
|
+
`'${attr.key}' is not a prop, flag, or enum value of ${component.name}`,
|
|
187
|
+
suggest(attr.key, [...component.props.keys()]),
|
|
188
|
+
);
|
|
189
|
+
} else if (hit.ambiguous) {
|
|
190
|
+
this.error(
|
|
191
|
+
node,
|
|
192
|
+
`'${attr.key}' is ambiguous on ${component.name} — it is a value of ${hit.ambiguous.join(' and ')}; use key=value`,
|
|
193
|
+
);
|
|
194
|
+
} else {
|
|
195
|
+
node.bound.props.set(hit.prop, hit.value);
|
|
196
|
+
}
|
|
197
|
+
} else if (attr.kind === 'neg') {
|
|
198
|
+
const hit = resolveWord(component, attr.key);
|
|
199
|
+
if (!hit || hit.ambiguous || typeof hit.value !== 'boolean') {
|
|
200
|
+
this.error(
|
|
201
|
+
node,
|
|
202
|
+
`'!${attr.key}' does not match a boolean prop of ${component.name}`,
|
|
203
|
+
suggest(attr.key, [...component.props.keys()].filter(p => component.props.get(p).isBoolean)),
|
|
204
|
+
);
|
|
205
|
+
} else {
|
|
206
|
+
node.bound.props.set(hit.prop, false);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
bindKv(node, component, attr) {
|
|
213
|
+
let key = attr.key.split('.')[0];
|
|
214
|
+
key = KEY_ALIASES[key] ?? key;
|
|
215
|
+
|
|
216
|
+
if (key === '__mainAxis' || key === '__crossAxis') {
|
|
217
|
+
key = resolveAxisKey(component, key);
|
|
218
|
+
}
|
|
219
|
+
if (key === 'opens') {
|
|
220
|
+
// Overlay trigger binding — expander turns this into an onClick TODO.
|
|
221
|
+
node.bound.props.set('__opens', attr.value);
|
|
222
|
+
if (!component.props.has('onClick') && !component.props.has('clickAction')) {
|
|
223
|
+
this.warn(node, `'opens=' on ${component.name}, which has no onClick — emitting a comment only`);
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const prop = component.props.get(key);
|
|
229
|
+
if (!prop) {
|
|
230
|
+
const fallbacks = suggest(key, [...component.props.keys()]);
|
|
231
|
+
this.error(
|
|
232
|
+
node,
|
|
233
|
+
`${component.name} has no prop '${key}'` +
|
|
234
|
+
(attr.raw && attr.raw !== key ? ` (from '${attr.raw}')` : ''),
|
|
235
|
+
fallbacks,
|
|
236
|
+
);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let value = attr.value;
|
|
241
|
+
if (key === 'columns') value = normalizeColumnsObject(value);
|
|
242
|
+
|
|
243
|
+
if (prop.enumValues && (typeof value === 'string' || typeof value === 'number')) {
|
|
244
|
+
if (!prop.enumValues.includes(value)) {
|
|
245
|
+
this.error(
|
|
246
|
+
node,
|
|
247
|
+
`${component.name}.${key} must be one of ${prop.enumValues.join(' | ')} — got '${value}'`,
|
|
248
|
+
typeof value === 'string' ? suggest(String(value), prop.enumValues.map(String)) : [],
|
|
249
|
+
);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
node.bound.props.set(key, value);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
bindMods(node, component) {
|
|
257
|
+
for (const mod of node.enumMods) {
|
|
258
|
+
const hit = resolveWord(component, mod);
|
|
259
|
+
if (!hit || hit.ambiguous) {
|
|
260
|
+
const enumPool = [...component.props.values()]
|
|
261
|
+
.flatMap(p => p.enumValues || [])
|
|
262
|
+
.map(String);
|
|
263
|
+
this.error(
|
|
264
|
+
node,
|
|
265
|
+
hit?.ambiguous
|
|
266
|
+
? `'.${mod}' is ambiguous on ${component.name} (${hit.ambiguous.join(', ')}) — use key=value`
|
|
267
|
+
: `'.${mod}' does not match an enum value of any ${component.name} prop`,
|
|
268
|
+
suggest(mod, enumPool),
|
|
269
|
+
);
|
|
270
|
+
} else {
|
|
271
|
+
node.bound.props.set(hit.prop, hit.value);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
bindSlots(node, component) {
|
|
277
|
+
for (const slot of node.slots) {
|
|
278
|
+
const prop = component.props.get(slot.key);
|
|
279
|
+
if (!prop) {
|
|
280
|
+
this.error(
|
|
281
|
+
node,
|
|
282
|
+
`${component.name} has no slot prop '${slot.key}'`,
|
|
283
|
+
suggest(slot.key, [...component.props.keys()].filter(k => component.props.get(k).isNode)),
|
|
284
|
+
);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (!prop.isNode && !prop.isFunction) {
|
|
288
|
+
this.warn(node, `${component.name}.${slot.key} is typed '${prop.type}' — slot value may not fit`);
|
|
289
|
+
}
|
|
290
|
+
if (slot.value && slot.value.subexpr) {
|
|
291
|
+
for (const sub of slot.value.subexpr) this.bindAny(sub, node);
|
|
292
|
+
}
|
|
293
|
+
if (slot.value && slot.value.hint) this.bindHintRef(node, slot.value.hint);
|
|
294
|
+
node.bound.slots.push(slot);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
bindHint(node) {
|
|
299
|
+
this.bindHintRef(node, node.hint);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
bindHintRef(node, hint) {
|
|
303
|
+
const block = this.blockIndex.get(normalizeBlockKey(hint.name));
|
|
304
|
+
if (block) {
|
|
305
|
+
hint.block = {name: block.dirName, description: block.description, category: block.category};
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const nearest = suggest(
|
|
309
|
+
normalizeBlockKey(hint.name),
|
|
310
|
+
[...this.blockIndex.keys()],
|
|
311
|
+
).map(k => this.blockIndex.get(k).dirName);
|
|
312
|
+
if (this.loose) {
|
|
313
|
+
this.warn(node, `Unknown block '{${hint.name}}' — emitting a TODO placeholder (running with --loose)`);
|
|
314
|
+
} else {
|
|
315
|
+
this.error(
|
|
316
|
+
node,
|
|
317
|
+
`Unknown block '{${hint.name}}' — block hints must name an existing template block`,
|
|
318
|
+
nearest.length > 0 ? nearest : ['run: astryx template --list --type block'],
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
checkPairings(node, component, parent) {
|
|
324
|
+
const parentName = parent?.bound?.component?.name ?? parent?.name;
|
|
325
|
+
if (component.name === 'AppShell' && parent) {
|
|
326
|
+
this.error(node, 'AppShell must be the outermost node');
|
|
327
|
+
}
|
|
328
|
+
if (component.name === 'GridSpan' && parentName !== 'Grid') {
|
|
329
|
+
this.error(node, 'GridSpan is only valid directly under Grid');
|
|
330
|
+
}
|
|
331
|
+
if (component.name === 'StackItem' && parentName !== 'VStack' && parentName !== 'HStack') {
|
|
332
|
+
this.error(node, 'StackItem is only valid directly under VStack/HStack');
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Validate (and bind) a parsed document in place.
|
|
339
|
+
*
|
|
340
|
+
* @param {{roots: any[], overlays: any[]}} doc
|
|
341
|
+
* @param {object} registry - from buildRegistry()
|
|
342
|
+
* @param {Array<object>} blocks - from template discovery (dirName et al)
|
|
343
|
+
* @param {{loose?: boolean}} [options]
|
|
344
|
+
* @returns {{errors: Array, warnings: Array}}
|
|
345
|
+
*/
|
|
346
|
+
export function validate(doc, registry, blocks, options = {}) {
|
|
347
|
+
const v = new Validation(registry, blocks, options);
|
|
348
|
+
for (const item of doc.roots) v.bindAny(item, null);
|
|
349
|
+
for (const item of doc.overlays) {
|
|
350
|
+
v.bindAny(item, null);
|
|
351
|
+
if (item.kind === 'node' && !item.id) {
|
|
352
|
+
v.warn(item, `Overlay ${item.name} has no #id — triggers cannot reference it`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return {errors: v.errors, warnings: v.warnings};
|
|
356
|
+
}
|