@astryxdesign/cli 0.6.4-canary.f0355e3 → 0.6.4-canary.f372744
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -6
- package/api/index.d.mts +2 -0
- package/api/index.mjs +2 -0
- package/api/json/index.ts +1 -0
- package/api/layout/_adapter.d.mts +34 -0
- package/api/layout/_adapter.mjs +148 -0
- package/api/layout/check/check.d.mts +16 -0
- package/api/layout/check/check.mjs +40 -0
- package/api/layout/expand/expand.d.mts +22 -0
- package/api/layout/expand/expand.mjs +155 -0
- package/api/layout/expand/expand.path-safety.test.mjs +53 -0
- package/api/layout/grammar/grammar.d.mts +13 -0
- package/api/layout/grammar/grammar.mjs +87 -0
- package/api/layout/layout.d.mts +6 -0
- package/api/layout/layout.mjs +17 -0
- package/api/layout/layout.test.mjs +297 -0
- package/api/layout/layout.type.d.mts +89 -0
- package/api/layout/layout.type.mjs +103 -0
- package/api/layout/layoutCheck.doc.d.mts +11 -0
- package/api/layout/layoutCheck.doc.mjs +85 -0
- package/api/layout/layoutExpand.doc.d.mts +11 -0
- package/api/layout/layoutExpand.doc.mjs +107 -0
- package/api/layout/layoutGrammar.doc.d.mts +11 -0
- package/api/layout/layoutGrammar.doc.mjs +57 -0
- package/api/template/template-integration.test.mjs +65 -1
- package/api/template/template.mjs +1 -1
- package/api/upgrade/run/run.mjs +1 -1
- package/authoring/config/config.doc.mjs +1 -1
- package/authoring/config/type.ts +2 -2
- package/authoring/doctypes/command/command.doc.mjs +1 -1
- package/authoring/doctypes/command/type.ts +1 -1
- package/clients/cli/command-result-coverage.test.mjs +7 -7
- package/clients/cli/commands/debug-result-summary.test.mjs +2 -2
- package/clients/cli/commands/layout-check.doc.mjs +65 -0
- package/clients/cli/commands/layout-expand.doc.mjs +83 -0
- package/clients/cli/commands/layout-grammar.doc.mjs +30 -0
- package/clients/cli/commands/layout.doc.mjs +34 -0
- package/clients/cli/commands/layout.error-codes.test.mjs +66 -0
- package/clients/cli/commands/layout.exit-parity.test.mjs +41 -0
- package/clients/cli/commands/layout.mjs +275 -0
- package/clients/cli/commands/layout.path-help.test.mjs +33 -0
- package/clients/cli/commands/layout.stdin-cap.test.mjs +47 -0
- package/clients/cli/commands/layout.text-fields.test.mjs +39 -0
- package/clients/cli/commands/text-json-parity.test.mjs +17 -0
- package/clients/cli/index.mjs +4 -0
- package/clients/cli/lib/exit-codes.test.mjs +8 -1
- package/clients/cli/lib/json-shim.test.mjs +20 -6
- package/clients/cli/lib/manifest.mjs +8 -0
- package/foundation/discovery/template-adapter.mjs +1 -1
- package/foundation/doc-compiler/doc-loads.test.mjs +12 -0
- package/foundation/doc-compiler/inputs.test.mjs +0 -1
- package/foundation/response/response-types.doc.mjs +17 -0
- package/foundation/xle/browser.d.mts +3 -3
- package/foundation/xle/browser.mjs +3 -3
- package/foundation/xle/expand.mjs +2 -2
- package/foundation/xle/parse.mjs +1 -1
- package/foundation/xle/print.mjs +2 -2
- package/foundation/xle/splice.mjs +1 -1
- package/package.json +9 -9
package/README.md
CHANGED
|
@@ -90,6 +90,7 @@ Options:
|
|
|
90
90
|
| `hook` | List hooks or print hook docs |
|
|
91
91
|
| `init` | Initialize the design system in your project |
|
|
92
92
|
| `integration` | Author and verify an Astryx integration package |
|
|
93
|
+
| `layout` | Generate XDS layouts from compressed expressions (XLE/XLO) |
|
|
93
94
|
| `search` | Search components, hooks, docs, and templates in one ranked list |
|
|
94
95
|
| `swizzle` | Copy component source for customization |
|
|
95
96
|
| `template` | List, show, or scaffold page and block templates |
|
|
@@ -478,6 +479,9 @@ Every response has a `type` discriminant. The full set is below (generated from
|
|
|
478
479
|
| `integration.template-conflicts` | The integration identity, structural issues, and non-blocking Core template-id conflicts as {id, severity: warning, integrationPackage, integrationType, integrationName, coreMatches, message, command}. |
|
|
479
480
|
| `integration.component-conflicts` | The integration identity, structural issues, and non-blocking conflicts where an integration component name is also owned by Core; each conflict includes the exact package-qualified command. |
|
|
480
481
|
| `integration.doc-conflicts` | The integration identity, structural issues, and Core doc overlaps. Each finding includes `severity` (`info` \| `error`) and `relationship` (`replaces` \| `extends` \| `accidental`). |
|
|
482
|
+
| `layout.expand` | The expansion: parsed form, generated TSX code, componentsUsed, states (count of useState hooks scaffolded), todos, blocksReferenced (each {name, mode}), warnings, and written (the output path, or null when nothing was written). |
|
|
483
|
+
| `layout.check` | The validation result: a valid flag, the detected form, errors (each with line/col, message, formatted text, and suggestions), warnings, and the expression re-printed in both canonical surfaces (compact and outline). |
|
|
484
|
+
| `layout.grammar` | The XLE/XLO grammar cheatsheet: a text field with the full reference plus an aliases map (short name → canonical component) generated from this install's registry. |
|
|
481
485
|
|
|
482
486
|
<!-- END GENERATED: response-types -->
|
|
483
487
|
<!-- Generated by scripts/generate-cli-readme.mjs from the response-types EnumDoc. Run `pnpm -F @astryxdesign/cli readme`. -->
|
|
@@ -578,12 +582,12 @@ There is no factory: write a plain object. For editor autocomplete and
|
|
|
578
582
|
type-checking, annotate it with the `AstryxConfig` type exported from
|
|
579
583
|
`@astryxdesign/cli/authoring`.
|
|
580
584
|
|
|
581
|
-
| Field | Type | Purpose
|
|
582
|
-
| ----------------------------- | ------------------------------ |
|
|
583
|
-
| `integrations` | `string[]` | Integration package names to load (see [Integrations](#integrations)).
|
|
584
|
-
| `issuesUrl` | `string` | Where "report an issue" links point for your project. Defaults to the core issue tracker.
|
|
585
|
-
| `hooks.postCodemod` | `PostCodemodHook[]` | Commands to run after `astryx upgrade` applies codemods (e.g. reinstall, rebuild, reformat).
|
|
586
|
-
| `experimental.xle.components` | `Record<string, XleComponent>` |
|
|
585
|
+
| Field | Type | Purpose |
|
|
586
|
+
| ----------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------- |
|
|
587
|
+
| `integrations` | `string[]` | Integration package names to load (see [Integrations](#integrations)). |
|
|
588
|
+
| `issuesUrl` | `string` | Where "report an issue" links point for your project. Defaults to the core issue tracker. |
|
|
589
|
+
| `hooks.postCodemod` | `PostCodemodHook[]` | Commands to run after `astryx upgrade` applies codemods (e.g. reinstall, rebuild, reformat). |
|
|
590
|
+
| `experimental.xle.components` | `Record<string, XleComponent>` | Register app-local components so layout (XLE) expressions can reference them by name. Unstable. |
|
|
587
591
|
|
|
588
592
|
The config is validated against a strict schema when the CLI loads it, so an
|
|
589
593
|
unknown field is a hard error rather than a silent no-op. `astryx doctor`
|
package/api/index.d.mts
CHANGED
|
@@ -33,12 +33,14 @@ export * from "./gap-report/gap-report.type.mjs";
|
|
|
33
33
|
export * from "./upgrade/upgrade.type.mjs";
|
|
34
34
|
export * from "./init/init.type.mjs";
|
|
35
35
|
export * from "./doctor/doctor.type.mjs";
|
|
36
|
+
export * from "./layout/layout.type.mjs";
|
|
36
37
|
export * from "./integration/integration-authoring.type.mjs";
|
|
37
38
|
export * from "./integration/pack-check.type.mjs";
|
|
38
39
|
export * from "./integration/validate-integration.type.mjs";
|
|
39
40
|
export * from "./integration/authoring-checks.type.mjs";
|
|
40
41
|
export type Logger = import("./logger.mjs").Logger;
|
|
41
42
|
export { themeBuild, themeAdd, themeTemplate, themeList, themeListAvailable, themeTargets, themePaletteGenerate, generateTonalPalette, listThemes } from "./theme/theme.mjs";
|
|
43
|
+
export { layoutExpand, layoutCheck, layoutGrammar } from "./layout/layout.mjs";
|
|
42
44
|
export { integrationAdd, integrationAddAgentDoc, integrationAddCodemod, integrationAddComponent, integrationAddDoc, integrationAddTemplate } from "./integration/add-contribution.mjs";
|
|
43
45
|
export { validateIntegration, summarizeIssues } from "./integration/validate-integration.mjs";
|
|
44
46
|
export { integrationTemplateConflicts, integrationComponentConflicts, integrationDocConflicts } from "./integration/authoring-checks.mjs";
|
package/api/index.mjs
CHANGED
|
@@ -44,6 +44,7 @@ export {gapReport} from './gap-report/gap-report.mjs';
|
|
|
44
44
|
export {upgrade} from './upgrade/upgrade.mjs';
|
|
45
45
|
export {init} from './init/init.mjs';
|
|
46
46
|
export {doctor} from './doctor/doctor.mjs';
|
|
47
|
+
export {layoutExpand, layoutCheck, layoutGrammar} from './layout/layout.mjs';
|
|
47
48
|
export {
|
|
48
49
|
integrationAdd,
|
|
49
50
|
integrationAddAgentDoc,
|
|
@@ -92,6 +93,7 @@ export * from './gap-report/gap-report.type.mjs';
|
|
|
92
93
|
export * from './upgrade/upgrade.type.mjs';
|
|
93
94
|
export * from './init/init.type.mjs';
|
|
94
95
|
export * from './doctor/doctor.type.mjs';
|
|
96
|
+
export * from './layout/layout.type.mjs';
|
|
95
97
|
export * from './integration/integration-authoring.type.mjs';
|
|
96
98
|
export * from './integration/pack-check.type.mjs';
|
|
97
99
|
export * from './integration/validate-integration.type.mjs';
|
package/api/json/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ export type * from '../gap-report/gap-report.type.mjs';
|
|
|
26
26
|
export type * from '../upgrade/upgrade.type.mjs';
|
|
27
27
|
export type * from '../init/init.type.mjs';
|
|
28
28
|
export type * from '../doctor/doctor.type.mjs';
|
|
29
|
+
export type * from '../layout/layout.type.mjs';
|
|
29
30
|
export type * from '../integration/validate-integration.type.mjs';
|
|
30
31
|
export type * from '../integration/authoring-checks.type.mjs';
|
|
31
32
|
export type * from '../integration/pack-check.type.mjs';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @generated by scripts/sync-api-types.mjs from the JSDoc in api/**/*.mjs.
|
|
2
|
+
// DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
|
|
3
|
+
|
|
4
|
+
/** @param {import('../../foundation/xle/xle-ast').RawIssue} issue */
|
|
5
|
+
export function formatIssue(issue: import("../../foundation/xle/xle-ast").RawIssue): string;
|
|
6
|
+
/**
|
|
7
|
+
* Parse + validate, throwing structured XDSErrors on failure.
|
|
8
|
+
* Returns {doc, registry, blocks, warnings}.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} expression
|
|
11
|
+
* @param {{form?: 'compact'|'outline'|'auto', loose?: boolean, cwd?: string}} [options]
|
|
12
|
+
*/
|
|
13
|
+
export function analyze(expression: string, { form, loose, cwd }?: {
|
|
14
|
+
form?: "compact" | "outline" | "auto";
|
|
15
|
+
loose?: boolean;
|
|
16
|
+
cwd?: string;
|
|
17
|
+
}): Promise<{
|
|
18
|
+
doc: import("../../foundation/xle/xle-ast").XLEDoc;
|
|
19
|
+
registry: import("../../foundation/xle/xle-ast").Registry;
|
|
20
|
+
blocks: LayoutBlock[];
|
|
21
|
+
errors: import("../../foundation/xle/xle-ast").RawIssue[];
|
|
22
|
+
warnings: import("../../foundation/xle/xle-ast").RawIssue[];
|
|
23
|
+
}>;
|
|
24
|
+
export type LayoutBlock = {
|
|
25
|
+
dirName: string;
|
|
26
|
+
name: string;
|
|
27
|
+
kind: "template" | "component";
|
|
28
|
+
type?: string | undefined;
|
|
29
|
+
description?: string | undefined;
|
|
30
|
+
category?: string | undefined;
|
|
31
|
+
importPath?: string | undefined;
|
|
32
|
+
filePath?: string | undefined;
|
|
33
|
+
isDefault?: boolean | undefined;
|
|
34
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Shared analysis layer for the `layout` command's expand/check leaves.
|
|
5
|
+
*
|
|
6
|
+
* Loads the block catalog (template blocks + app-registered components), builds
|
|
7
|
+
* the branch registry, and parses+validates a layout expression into a doc +
|
|
8
|
+
* issues. Both the expand and check leaves sit on `analyze`; neither parses or
|
|
9
|
+
* validates directly. (The grammar leaf needs none of this — it only reads the
|
|
10
|
+
* registry alias table.)
|
|
11
|
+
*
|
|
12
|
+
* @input expression string (+ options)
|
|
13
|
+
* @output analyze() -> {doc, registry, blocks, errors, warnings}; formatIssue()
|
|
14
|
+
* @position api — shared core over lib/xle; leaves in expand/ + check/ project it
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {AstryxError} from '../error.mjs';
|
|
18
|
+
import {ERROR_CODES} from '../../foundation/response/error-codes.mjs';
|
|
19
|
+
import {parse, XLEParseError} from '../../foundation/xle/parse.mjs';
|
|
20
|
+
import {validate} from '../../foundation/xle/validate.mjs';
|
|
21
|
+
import {buildRegistry} from '../../foundation/xle/registry.mjs';
|
|
22
|
+
import {discoverTemplates} from '../template/template.mjs';
|
|
23
|
+
import {Project} from '../../foundation/config/project.mjs';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {object} LayoutBlock
|
|
27
|
+
* @property {string} dirName
|
|
28
|
+
* @property {string} name
|
|
29
|
+
* @property {'template'|'component'} kind
|
|
30
|
+
* @property {string} [type]
|
|
31
|
+
* @property {string} [description]
|
|
32
|
+
* @property {string} [category]
|
|
33
|
+
* @property {string} [importPath]
|
|
34
|
+
* @property {string} [filePath]
|
|
35
|
+
* @property {boolean} [isDefault]
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The catalog a `{hint}` can resolve to: template blocks (spliced inline) plus
|
|
40
|
+
* any app-registered local components from astryx.config.mjs
|
|
41
|
+
* `experimental.xle.components` (imported by name). App components are how XLE
|
|
42
|
+
* reaches domain pieces — the KpiCard/chart/drawer set that the
|
|
43
|
+
* @astryxdesign/core registry can't see.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} cwd
|
|
46
|
+
* @returns {Promise<LayoutBlock[]>}
|
|
47
|
+
*/
|
|
48
|
+
async function loadBlocks(cwd) {
|
|
49
|
+
/** @type {LayoutBlock[]} */
|
|
50
|
+
const blocks = [];
|
|
51
|
+
try {
|
|
52
|
+
const all = await discoverTemplates(cwd);
|
|
53
|
+
const templates = all.filter(template => template.type === 'block');
|
|
54
|
+
/** @type {Map<string, import('../../foundation/discovery/template-adapter.mjs').DiscoveredTemplate>} */
|
|
55
|
+
const byId = new Map();
|
|
56
|
+
// Exact ids are the fallback. Active replacement aliases override them in a
|
|
57
|
+
// second pass, matching template() regardless of discovery/display order.
|
|
58
|
+
for (const template of templates) byId.set(template.dirName, template);
|
|
59
|
+
for (const template of templates) {
|
|
60
|
+
if (template.replaces != null) byId.set(template.replaces, template);
|
|
61
|
+
}
|
|
62
|
+
for (const [id, template] of byId) {
|
|
63
|
+
blocks.push({...template, dirName: id, kind: 'template'});
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// discovery is best-effort
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const project = await Project.load(cwd);
|
|
70
|
+
/** @type {Record<string, {from?: string, description?: string, default?: boolean}>} */
|
|
71
|
+
const components = project.config.experimental?.xle?.components ?? {};
|
|
72
|
+
for (const [name, spec] of Object.entries(components)) {
|
|
73
|
+
const importPath = spec.from;
|
|
74
|
+
if (!importPath) continue;
|
|
75
|
+
blocks.push({
|
|
76
|
+
type: 'block',
|
|
77
|
+
kind: 'component',
|
|
78
|
+
dirName: name,
|
|
79
|
+
name,
|
|
80
|
+
description: spec.description ?? '',
|
|
81
|
+
category: 'app',
|
|
82
|
+
importPath,
|
|
83
|
+
isDefault: Boolean(spec.default),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// config is optional
|
|
88
|
+
}
|
|
89
|
+
return blocks;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @param {import('../../foundation/xle/xle-ast').RawIssue} issue */
|
|
93
|
+
export function formatIssue(issue) {
|
|
94
|
+
const where = issue.line != null ? `line ${issue.line}: ` : '';
|
|
95
|
+
return `${where}${issue.message}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Parse + validate, throwing structured XDSErrors on failure.
|
|
100
|
+
* Returns {doc, registry, blocks, warnings}.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} expression
|
|
103
|
+
* @param {{form?: 'compact'|'outline'|'auto', loose?: boolean, cwd?: string}} [options]
|
|
104
|
+
*/
|
|
105
|
+
export async function analyze(
|
|
106
|
+
expression,
|
|
107
|
+
{form = 'auto', loose = false, cwd = process.cwd()} = {},
|
|
108
|
+
) {
|
|
109
|
+
// Validate inputs in the API (not just the CLI): an empty expression or an
|
|
110
|
+
// unknown --form must error, not silently parse as an empty/compact layout.
|
|
111
|
+
if (typeof expression !== 'string' || expression.trim() === '') {
|
|
112
|
+
throw new AstryxError(
|
|
113
|
+
'Layout expression is empty.',
|
|
114
|
+
undefined,
|
|
115
|
+
ERROR_CODES.ERR_INVALID_ARGUMENT,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (form !== 'compact' && form !== 'outline' && form !== 'auto') {
|
|
119
|
+
throw new AstryxError(
|
|
120
|
+
`Invalid form "${form}". Must be one of: compact, outline, auto.`,
|
|
121
|
+
undefined,
|
|
122
|
+
ERROR_CODES.ERR_INVALID_OPTION,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const registry =
|
|
126
|
+
/** @type {import('../../foundation/xle/xle-ast').Registry} */ (
|
|
127
|
+
/** @type {unknown} */ (await buildRegistry({cwd}))
|
|
128
|
+
);
|
|
129
|
+
const blocks = await loadBlocks(cwd);
|
|
130
|
+
|
|
131
|
+
/** @type {import('../../foundation/xle/xle-ast').XLEDoc} */
|
|
132
|
+
let doc;
|
|
133
|
+
try {
|
|
134
|
+
doc = parse(expression, {form});
|
|
135
|
+
} catch (e) {
|
|
136
|
+
if (e instanceof XLEParseError) {
|
|
137
|
+
throw new AstryxError(
|
|
138
|
+
`Layout expression syntax error at line ${e.line}, col ${e.col}: ${e.message}`,
|
|
139
|
+
undefined,
|
|
140
|
+
ERROR_CODES.ERR_LAYOUT_PARSE,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
throw e;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const {errors, warnings} = validate(doc, registry, blocks, {loose});
|
|
147
|
+
return {doc, registry, blocks, errors, warnings};
|
|
148
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @generated by scripts/sync-api-types.mjs from the JSDoc in api/**/*.mjs.
|
|
2
|
+
// DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `astryx layout check "<expr>" [--form compact|outline]`
|
|
6
|
+
* Validates without expanding; echoes both canonical surfaces.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} expression
|
|
9
|
+
* @param {{form?: 'compact'|'outline'|'auto', loose?: boolean, cwd?: string}} [options]
|
|
10
|
+
* @returns {Promise<import('../layout.type.mjs').LayoutCheckResponse>}
|
|
11
|
+
*/
|
|
12
|
+
export function layoutCheck(expression: string, options?: {
|
|
13
|
+
form?: "compact" | "outline" | "auto";
|
|
14
|
+
loose?: boolean;
|
|
15
|
+
cwd?: string;
|
|
16
|
+
}): Promise<import("../layout.type.mjs").LayoutCheckResponse>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file `astryx layout check` leaf — validate a layout expression without
|
|
5
|
+
* expanding, echoing both canonical surfaces (compact + outline). Resolution +
|
|
6
|
+
* validation come from ../_adapter.mjs (analyze); this leaf projects the
|
|
7
|
+
* `layout.check` envelope.
|
|
8
|
+
*
|
|
9
|
+
* @input expression string (+ options)
|
|
10
|
+
* @output {type:'layout.check', data}
|
|
11
|
+
* @position api — leaf over ../_adapter.mjs + lib/xle/print
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {toCompact, toOutline} from '../../../foundation/xle/print.mjs';
|
|
15
|
+
import {analyze, formatIssue} from '../_adapter.mjs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `astryx layout check "<expr>" [--form compact|outline]`
|
|
19
|
+
* Validates without expanding; echoes both canonical surfaces.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} expression
|
|
22
|
+
* @param {{form?: 'compact'|'outline'|'auto', loose?: boolean, cwd?: string}} [options]
|
|
23
|
+
* @returns {Promise<import('../layout.type.mjs').LayoutCheckResponse>}
|
|
24
|
+
*/
|
|
25
|
+
export async function layoutCheck(expression, options = {}) {
|
|
26
|
+
const {form = 'auto', loose = false, cwd = process.cwd()} = options;
|
|
27
|
+
const {doc, errors, warnings} = await analyze(expression, {form, loose, cwd});
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
type: 'layout.check',
|
|
31
|
+
data: {
|
|
32
|
+
valid: errors.length === 0,
|
|
33
|
+
form: doc.form,
|
|
34
|
+
errors: errors.map(e => ({...e, formatted: formatIssue(e)})),
|
|
35
|
+
warnings: warnings.map(formatIssue),
|
|
36
|
+
compact: toCompact(doc),
|
|
37
|
+
outline: toOutline(doc),
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// @generated by scripts/sync-api-types.mjs from the JSDoc in api/**/*.mjs.
|
|
2
|
+
// DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `astryx layout expand "<expr>" [path]`
|
|
6
|
+
*
|
|
7
|
+
* @param {string} expression
|
|
8
|
+
* @param {object} [options]
|
|
9
|
+
* @param {string} [options.targetPath] - write TSX here (validated against cwd)
|
|
10
|
+
* @param {'compact'|'outline'|'auto'} [options.form]
|
|
11
|
+
* @param {boolean} [options.loose] - downgrade unknown {hints} to TODO warnings
|
|
12
|
+
* @param {string} [options.name] - generated component name
|
|
13
|
+
* @param {string} [options.cwd]
|
|
14
|
+
* @returns {Promise<import('../layout.type.mjs').LayoutExpandResponse>}
|
|
15
|
+
*/
|
|
16
|
+
export function layoutExpand(expression: string, options?: {
|
|
17
|
+
targetPath?: string | undefined;
|
|
18
|
+
form?: "compact" | "auto" | "outline" | undefined;
|
|
19
|
+
loose?: boolean | undefined;
|
|
20
|
+
name?: string | undefined;
|
|
21
|
+
cwd?: string | undefined;
|
|
22
|
+
}): Promise<import("../layout.type.mjs").LayoutExpandResponse>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file `astryx layout expand` leaf — expand a validated layout expression into
|
|
5
|
+
* XDS TSX (optionally written to disk). Resolution + validation come from
|
|
6
|
+
* ../_adapter.mjs (analyze); this leaf owns block-module assembly, code
|
|
7
|
+
* generation, and the path-safe write, and projects the `layout.expand` envelope.
|
|
8
|
+
*
|
|
9
|
+
* @input expression string (+ options)
|
|
10
|
+
* @output {type:'layout.expand', data}
|
|
11
|
+
* @position api — leaf over ../_adapter.mjs + lib/xle/expand
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import {AstryxError} from '../../error.mjs';
|
|
17
|
+
import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
|
|
18
|
+
import {assertWithin, isFilePathArg, PathSafetyError} from '../../../foundation/fs/path-safety.mjs';
|
|
19
|
+
import {detectForm} from '../../../foundation/xle/parse.mjs';
|
|
20
|
+
import {expand} from '../../../foundation/xle/expand.mjs';
|
|
21
|
+
import {stripTemplateAssetRefs} from '../../template/template.mjs';
|
|
22
|
+
import {analyze, formatIssue} from '../_adapter.mjs';
|
|
23
|
+
|
|
24
|
+
/** @param {string} name */
|
|
25
|
+
const normKey = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Collect the block names referenced by {hints} anywhere in the doc.
|
|
29
|
+
* @param {import('../../../foundation/xle/xle-ast').XLEDoc} doc
|
|
30
|
+
*/
|
|
31
|
+
function collectHintNames(doc) {
|
|
32
|
+
/** @type {Set<string>} */
|
|
33
|
+
const names = new Set();
|
|
34
|
+
/** @param {import('../../../foundation/xle/xle-ast').XLEItem} node */
|
|
35
|
+
const visitNode = (node) => {
|
|
36
|
+
if (!node || node.kind === 'group') {
|
|
37
|
+
(node?.children || []).forEach(visit);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (node.hint?.block) names.add(node.hint.block.name);
|
|
41
|
+
for (const slot of node.slots || []) {
|
|
42
|
+
const value = slot.value;
|
|
43
|
+
if (value && typeof value === 'object' && 'hint' in value && value.hint?.block) {
|
|
44
|
+
names.add(value.hint.block.name);
|
|
45
|
+
}
|
|
46
|
+
if (value && typeof value === 'object' && 'subexpr' in value) {
|
|
47
|
+
(value.subexpr || []).forEach(visit);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
(node.children || []).forEach(visit);
|
|
51
|
+
};
|
|
52
|
+
/** @param {import('../../../foundation/xle/xle-ast').XLEItem} item */
|
|
53
|
+
const visit = (item) => (item?.kind === 'group' ? item.children.forEach(visit) : visitNode(item));
|
|
54
|
+
doc.roots.forEach(visit);
|
|
55
|
+
doc.overlays.forEach(visit);
|
|
56
|
+
return names;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the blockModules map expand() needs: import-mode for app components,
|
|
61
|
+
* splice-mode (reading + asset-stripping the block source) for template blocks.
|
|
62
|
+
* Only blocks actually referenced are read.
|
|
63
|
+
*
|
|
64
|
+
* @param {import('../../../foundation/xle/xle-ast').XLEDoc} doc
|
|
65
|
+
* @param {import('../_adapter.mjs').LayoutBlock[]} blocks
|
|
66
|
+
* @returns {Map<string, import('../../../foundation/xle/xle-ast').BlockModule>}
|
|
67
|
+
*/
|
|
68
|
+
function buildBlockModules(doc, blocks) {
|
|
69
|
+
const referenced = collectHintNames(doc);
|
|
70
|
+
if (referenced.size === 0) return new Map();
|
|
71
|
+
const byKey = new Map(blocks.map(b => [normKey(b.dirName), b]));
|
|
72
|
+
/** @type {Map<string, import('../../../foundation/xle/xle-ast').BlockModule>} */
|
|
73
|
+
const modules = new Map();
|
|
74
|
+
for (const name of referenced) {
|
|
75
|
+
const block = byKey.get(normKey(name));
|
|
76
|
+
if (!block) continue;
|
|
77
|
+
if (block.kind === 'component') {
|
|
78
|
+
modules.set(name, /** @type {import('../../../foundation/xle/xle-ast').BlockModule} */ (/** @type {unknown} */ ({mode: 'import', componentName: block.name, importPath: block.importPath, isDefault: block.isDefault})));
|
|
79
|
+
} else if (block.filePath && fs.existsSync(block.filePath)) {
|
|
80
|
+
modules.set(name, /** @type {import('../../../foundation/xle/xle-ast').BlockModule} */ ({mode: 'splice', componentName: block.dirName, source: stripTemplateAssetRefs(fs.readFileSync(block.filePath, 'utf-8'))}));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return modules;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `astryx layout expand "<expr>" [path]`
|
|
88
|
+
*
|
|
89
|
+
* @param {string} expression
|
|
90
|
+
* @param {object} [options]
|
|
91
|
+
* @param {string} [options.targetPath] - write TSX here (validated against cwd)
|
|
92
|
+
* @param {'compact'|'outline'|'auto'} [options.form]
|
|
93
|
+
* @param {boolean} [options.loose] - downgrade unknown {hints} to TODO warnings
|
|
94
|
+
* @param {string} [options.name] - generated component name
|
|
95
|
+
* @param {string} [options.cwd]
|
|
96
|
+
* @returns {Promise<import('../layout.type.mjs').LayoutExpandResponse>}
|
|
97
|
+
*/
|
|
98
|
+
export async function layoutExpand(expression, options = {}) {
|
|
99
|
+
const {targetPath, form = 'auto', loose = false, name, cwd = process.cwd()} = options;
|
|
100
|
+
const {doc, registry, blocks, errors, warnings} = await analyze(expression, {form, loose, cwd});
|
|
101
|
+
|
|
102
|
+
if (errors.length > 0) {
|
|
103
|
+
throw new AstryxError(
|
|
104
|
+
`Layout expression is invalid:\n` + errors.map(e => ` - ${formatIssue(e)}`).join('\n'),
|
|
105
|
+
errors.flatMap(e => (e.suggestions || []).map(s => ({name: s, reason: 'did you mean this?'}))),
|
|
106
|
+
ERROR_CODES.ERR_LAYOUT_INVALID,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const componentName = name || 'GeneratedLayout';
|
|
111
|
+
if (!/^[A-Z][A-Za-z0-9]*$/.test(componentName)) {
|
|
112
|
+
throw new AstryxError(
|
|
113
|
+
`--name must be a PascalCase component name, got '${componentName}'`,
|
|
114
|
+
undefined,
|
|
115
|
+
ERROR_CODES.ERR_INVALID_ARGUMENT,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const blockModules = buildBlockModules(doc, blocks);
|
|
119
|
+
const result = expand(doc, registry, {componentName, blockModules});
|
|
120
|
+
|
|
121
|
+
let written = null;
|
|
122
|
+
if (targetPath) {
|
|
123
|
+
// The guard sees the file that will be written, not only its directory:
|
|
124
|
+
// a symlink at that name would otherwise carry the write outside the root.
|
|
125
|
+
const fileTarget = isFilePathArg(targetPath)
|
|
126
|
+
? targetPath
|
|
127
|
+
: path.join(targetPath, `${componentName}.tsx`);
|
|
128
|
+
let filePath;
|
|
129
|
+
try {
|
|
130
|
+
filePath = assertWithin(fileTarget, cwd, {label: 'layout target path'});
|
|
131
|
+
} catch (err) {
|
|
132
|
+
if (err instanceof PathSafetyError) {
|
|
133
|
+
throw new AstryxError(err.message, undefined, ERROR_CODES.ERR_PATH_TRAVERSAL);
|
|
134
|
+
}
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
fs.mkdirSync(path.dirname(filePath), {recursive: true});
|
|
138
|
+
fs.writeFileSync(filePath, result.code);
|
|
139
|
+
written = path.relative(cwd, filePath);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
type: 'layout.expand',
|
|
144
|
+
data: {
|
|
145
|
+
form: form === 'auto' ? detectForm(expression) : form,
|
|
146
|
+
code: result.code,
|
|
147
|
+
componentsUsed: result.componentsUsed,
|
|
148
|
+
states: result.states,
|
|
149
|
+
todos: result.todos,
|
|
150
|
+
blocksReferenced: [...blockModules.entries()].map(([name, m]) => ({name, mode: m.mode})),
|
|
151
|
+
warnings: warnings.map(formatIssue),
|
|
152
|
+
written,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Path-safety tests for the `layout.expand` write. The file written, not
|
|
5
|
+
* only the directory it lands in, must stay inside the project root.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {describe, it, expect, beforeAll} from 'vitest';
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as os from 'node:os';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import {layoutExpand} from '../layout.mjs';
|
|
13
|
+
import {buildRegistry} from '../../../foundation/xle/registry.mjs';
|
|
14
|
+
|
|
15
|
+
const SLOW = 30_000;
|
|
16
|
+
|
|
17
|
+
// The registry imports every component doc on first use; warm it once.
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
await buildRegistry();
|
|
20
|
+
}, 120_000);
|
|
21
|
+
|
|
22
|
+
describe('layout.expand — write confinement', () => {
|
|
23
|
+
it('rejects a directory target whose generated file is a symlink leading outside cwd', async () => {
|
|
24
|
+
// Inside the workspace so @astryxdesign/core resolves.
|
|
25
|
+
const cwd = fs.mkdtempSync(path.join(process.cwd(), '.xle-confine-test-'));
|
|
26
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'xle-confine-outside-'));
|
|
27
|
+
try {
|
|
28
|
+
const victim = path.join(outside, 'victim.tsx');
|
|
29
|
+
fs.writeFileSync(victim, 'OUTSIDE');
|
|
30
|
+
fs.mkdirSync(path.join(cwd, 'out'));
|
|
31
|
+
fs.symlinkSync(victim, path.join(cwd, 'out', 'GeneratedLayout.tsx'));
|
|
32
|
+
|
|
33
|
+
await expect(
|
|
34
|
+
layoutExpand('V > B', {targetPath: './out', cwd}),
|
|
35
|
+
).rejects.toMatchObject({code: 'ERR_PATH_TRAVERSAL'});
|
|
36
|
+
expect(fs.readFileSync(victim, 'utf-8')).toBe('OUTSIDE');
|
|
37
|
+
} finally {
|
|
38
|
+
fs.rmSync(cwd, {recursive: true, force: true});
|
|
39
|
+
fs.rmSync(outside, {recursive: true, force: true});
|
|
40
|
+
}
|
|
41
|
+
}, SLOW);
|
|
42
|
+
|
|
43
|
+
it('still writes <Name>.tsx into a plain directory target', async () => {
|
|
44
|
+
const cwd = fs.mkdtempSync(path.join(process.cwd(), '.xle-confine-test-'));
|
|
45
|
+
try {
|
|
46
|
+
const result = await layoutExpand('V > B', {targetPath: './out', name: 'Demo', cwd});
|
|
47
|
+
expect(result.data.written).toBe(path.join('out', 'Demo.tsx'));
|
|
48
|
+
expect(fs.existsSync(path.join(cwd, 'out', 'Demo.tsx'))).toBe(true);
|
|
49
|
+
} finally {
|
|
50
|
+
fs.rmSync(cwd, {recursive: true, force: true});
|
|
51
|
+
}
|
|
52
|
+
}, SLOW);
|
|
53
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// @generated by scripts/sync-api-types.mjs from the JSDoc in api/**/*.mjs.
|
|
2
|
+
// DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `astryx layout grammar` — the agent cheatsheet, with the alias table
|
|
6
|
+
* generated from this branch's registry (never hand-maintained).
|
|
7
|
+
*
|
|
8
|
+
* @param {{cwd?: string}} [options]
|
|
9
|
+
* @returns {Promise<import('../layout.type.mjs').LayoutGrammarResponse>}
|
|
10
|
+
*/
|
|
11
|
+
export function layoutGrammar(options?: {
|
|
12
|
+
cwd?: string;
|
|
13
|
+
}): Promise<import("../layout.type.mjs").LayoutGrammarResponse>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file `astryx layout grammar` leaf — the agent cheatsheet, with the alias
|
|
5
|
+
* table generated from this branch's registry (never hand-maintained). Reads
|
|
6
|
+
* only the registry; shares nothing with expand/check, so it has no adapter.
|
|
7
|
+
*
|
|
8
|
+
* @output {type:'layout.grammar', data}
|
|
9
|
+
* @position api — leaf over lib/xle/registry
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {buildRegistry} from '../../../foundation/xle/registry.mjs';
|
|
13
|
+
import {MAX_REPEAT} from '../../../foundation/xle/expand.mjs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `astryx layout grammar` — the agent cheatsheet, with the alias table
|
|
17
|
+
* generated from this branch's registry (never hand-maintained).
|
|
18
|
+
*
|
|
19
|
+
* @param {{cwd?: string}} [options]
|
|
20
|
+
* @returns {Promise<import('../layout.type.mjs').LayoutGrammarResponse>}
|
|
21
|
+
*/
|
|
22
|
+
export async function layoutGrammar(options = {}) {
|
|
23
|
+
const {cwd = process.cwd()} = options;
|
|
24
|
+
const registry = await buildRegistry({cwd});
|
|
25
|
+
|
|
26
|
+
/** @type {string[]} */
|
|
27
|
+
const aliasLines = [];
|
|
28
|
+
/** @type {Map<string, string[]>} */
|
|
29
|
+
const byTarget = new Map();
|
|
30
|
+
for (const [alias, target] of registry.aliases) {
|
|
31
|
+
if (!byTarget.has(target)) byTarget.set(target, []);
|
|
32
|
+
byTarget.get(target)?.push(alias);
|
|
33
|
+
}
|
|
34
|
+
for (const [target, aliases] of [...byTarget.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
35
|
+
aliasLines.push(`${aliases.join('/')}=${target}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const text = `XLE/XLO — XDS layout expressions (branch-generated; aliases reflect this install)
|
|
39
|
+
|
|
40
|
+
WORKFLOW
|
|
41
|
+
astryx layout check "<expr>" validate; echoes canonical compact + outline forms
|
|
42
|
+
astryx layout expand "<expr>" [path] emit validated TSX (path optional; --name <Pascal>)
|
|
43
|
+
Errors carry line/col + suggestions. Fix and resubmit; nothing is guessed.
|
|
44
|
+
|
|
45
|
+
TWO SURFACES, ONE LANGUAGE (autodetected; --form to force)
|
|
46
|
+
compact: A[cp6 @topNav=TN] > L > LC > S[p6] > (C{card-callout}*4) + T
|
|
47
|
+
outline: indentation = nesting · same-indent = siblings · "repeat N:" block = (...)*N
|
|
48
|
+
slot lines: topNav: TN (or a block: topNav:\\n TN ...)
|
|
49
|
+
|
|
50
|
+
NODE ANATOMY Name#id.enum"payload"[attrs]{hint}*N > children
|
|
51
|
+
.enum unique enum value of any prop: Bd.success Tx.lg B.primary
|
|
52
|
+
"payload" primary text prop (label/title/heading) or text child: TI"Email" B"Save"
|
|
53
|
+
{hint} kebab-case template/component reference (see TEMPLATE REFERENCING) — NEVER text
|
|
54
|
+
*N / xN repeat, at most ${MAX_REPEAT} copies (use $ for the counter: Tk"item-$"*3)
|
|
55
|
+
trailing ! initial selection for scaffolded state: Tab"Overview"!
|
|
56
|
+
|
|
57
|
+
ATTRS [...] (outline: bare tokens after the name, no brackets)
|
|
58
|
+
fused p6 g4 c4 w240 h2 cp2 mw960 rg2 cg2 (per-component: padding lives on Card/Section/AppShell.cp — p6 on AppShell/Layout/VStack errors with a correction)
|
|
59
|
+
key=value t=email href='/x' c{min:340} dv=[top,bottom] — keys validated per component
|
|
60
|
+
flags req opt dis striped hover divider … (isX/hasX props) · negate: !scroll
|
|
61
|
+
align j= main axis, a= cross axis — expander picks hAlign/vAlign per stack direction
|
|
62
|
+
slots @slotName=Node | @slotName=(sub > expr) | @slotName='text' | @slotName=#id
|
|
63
|
+
trigger opens=#id (a plain attr, no @ — binds an onClick that opens the overlay)
|
|
64
|
+
fill on a stack child → wraps in <StackItem size="fill">
|
|
65
|
+
|
|
66
|
+
TEMPLATE REFERENCING ({hint} pulls in real content — this is how XLE reaches past the @astryxdesign/core shell)
|
|
67
|
+
C{card-callout} splice a template block (astryx template --list --type block):
|
|
68
|
+
the block is co-defined once in the file, referenced, imports merged
|
|
69
|
+
{kpi-card} standalone reference (no wrapper element) — place a component directly
|
|
70
|
+
{kpi-card}*4 repeat a reference; the definition/import is emitted once
|
|
71
|
+
app components register local ones in astryx.config.mjs to import them by name:
|
|
72
|
+
export default {experimental: {xle: {components: {KpiCard: {from: '@/components/KpiCard'}}}}}
|
|
73
|
+
then {kpi-card} → import {KpiCard} + <KpiCard /> (kebab ↔ Pascal)
|
|
74
|
+
|
|
75
|
+
STRUCTURE THE EXPANDER HANDLES
|
|
76
|
+
Layout > LH + LC + LF + LP children auto-route into header/content/footer/start slots
|
|
77
|
+
T > (TR>THC*4) + (TR>TC*4)*6 rows partition into TableHeader/TableBody automatically
|
|
78
|
+
TabList/inputs required value+onChange scaffold typed useState automatically
|
|
79
|
+
overlays compact: tree ;; Dlg#confirm[...] · outline: overlays: section
|
|
80
|
+
trigger: B"Delete"[opens=#confirm]
|
|
81
|
+
|
|
82
|
+
ALIASES (full component names always valid; XDS prefix optional)
|
|
83
|
+
${aliasLines.join(' ')}
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
return {type: 'layout.grammar', data: {text, aliases: Object.fromEntries(registry.aliases)}};
|
|
87
|
+
}
|